Food Tracker (React + Express)
A Complete 12-Chapter Full-Stack JavaScript Course
Table of Contents
- Project Overview & Full-Stack JS Setup
- Data Modeling & Express API Routes
- Barcode Lookup: Integrating Open Food Facts
- Camera-Based Barcode Scanning in React
- Building the Add-Item Flow
- Expiry Alerts
- Item History & Live Search-as-You-Type
- Marking Items Used
- Recipe Lookup with TheMealDB
- State Management Across the App
- Deployment
- Capstone: A Complete, Working Food Tracker
Project Overview & Full-Stack JS Setup
Food Tracker (React + Express)
Chapter 1 · Project Overview & Full-Stack JS Setup
This is one of four courses building the exact same app in four genuinely different architectures — Food Tracker (FastAPI), Food Tracker (Django), and Food Tracker (React + Firebase) are its siblings. Every one of them scans a barcode, tracks a use-by date, and alerts you before something goes to waste. This course's own angle is the most straightforward of the four to describe, and one of the most common in real professional practice: the same language, front and back.
What the App Actually Does
Before any architecture talk, the shared spec every course in the quartet is building toward:
- Scan a barcode with a phone or webcam camera, look it up against Open Food Facts (free, open, no API key) to fetch the product's name and details automatically.
- Record a use-by date for the item, and see it flagged once it's expiring soon.
- Keep a full history of every item ever added — some still active with a real expiry date, some already marked used with no expiry date at all — searchable in real time as you type, so re-adding something you've bought before is fast.
- Look up recipes via TheMealDB (also free, no key) that use ingredients close to expiring.
A weekly meal planner is explicitly out of scope for all four courses — named future work, not something any of them will build.
Why "One Language, Both Ends" Is a Genuine Case, Not Just Convenience
Food Tracker (FastAPI) and Food Tracker (Django) both write their backend in Python and their frontend in JavaScript — a real, unavoidable context switch every time work crosses that boundary. This course removes that switch entirely: React on the client, Node running an Express server, both written in JavaScript. The practical payoffs are concrete, not just aesthetic:
- One shared data format, natively. JSON is JavaScript's own native object literal syntax on both sides — no serializing a Python dict into JSON on the way out and parsing it back into a dict on the way in. A JS object built on the server and a JS object consumed on the client are structurally the same kind of thing.
- One package ecosystem.
npmserves both halves of the app — no separatepip/requirements.txtworld to keep in sync with a separate JS toolchain. - One team, fewer context switches. A developer fixing a bug that spans "what the API returns" and "how the UI renders it" can stay in one language the entire time.
The honest caveat, named here so it doesn't need repeating later: sharing a language doesn't automatically mean sharing types across the network boundary. Without extra tooling (shared TypeScript interfaces, a schema-validation library), the Express server and the React client can still silently drift out of sync about what shape a response actually has — this course builds without that extra layer, matching its own realistic scope, and names the tradeoff honestly rather than pretending "same language" solves it for free.
The Architecture Contrast, Precisely
| Course | Backend | Where business logic lives |
|---|---|---|
| Food Tracker (FastAPI) | A FastAPI process you write, run, and deploy | Python code, running on a server you manage |
| Food Tracker (Django) | A Django process you write, run, and deploy | Python code, running on a server you manage |
| Food Tracker (React + Express) | A Node/Express process you write, run, and deploy | JavaScript code, the same language as the frontend, running on a server you manage |
| Food Tracker (React + Firebase) | No server process you write or deploy | Mostly Security Rules (configuration) plus a few Cloud Functions |
Scaffolding the Project
Two separate processes, kept in one repository: a Vite-powered React client, and a plain Express server.
A minimal server, confirming the setup works end to end before any real feature exists:
cors() the browser blocks every request from the React app to the Express API outright. This is a genuinely common first-hour stumbling block for anyone new to a two-process full-stack setup — worth understanding now rather than debugging blind later.
node --watch server/index.js (built into modern Node, no extra dependency needed) restarts the Express process automatically on every file save — the same convenience Vite already gives the React side for free.
Where This Course Is Headed
Data modeling and Express API routes, barcode lookup, the camera-scanning React component (shared almost verbatim with Food Tracker (React + Firebase)), the add-item flow, expiry alerts, item history with live search, marking items used, recipe lookup, cross-cutting state management once every feature needs to talk to every other feature, deployment, and a capstone tying every chapter into one complete, working app.
Hands-On Exercises
In one sentence, state this course's own core architectural claim. Then explain what specifically breaks (or doesn't break) that claim once network requests are involved, using this chapter's own honest caveat about type-sharing.
📄 View solutionExplain why the React dev server and the Express server being on different ports causes a real problem in the browser, and what cors() actually does about it.
📄 View solutionUsing this chapter's own comparison table, explain how this course's "where business logic lives" column differs from both Python siblings' own column and from the Firebase sibling's own column.
📄 View solutionChapter 1 Quick Reference
- The shared app — barcode scan (Open Food Facts) → expiry tracking → alerts → searchable history → recipe lookup (TheMealDB); no meal planner
- This course's own throughline: the same language, JavaScript, runs on both the client and the server
- Real payoff: one native data format (JSON), one package ecosystem (npm)
- Honest limit: same language does not mean shared types across the network without extra tooling this course doesn't add
- Setup: Vite scaffolds the React client; a plain Express server with cors() and express.json() is the backend
- Next chapter: Data Modeling & Express API Routes
Data Modeling & Express API Routes
Food Tracker (React + Express)
Chapter 2 · Data Modeling & Express API Routes
Every course in this quartet stores the same shape of data. What genuinely differs here is deliberate: this course reaches for the plainest possible way to store it — no ORM at all, just SQL, written directly.
The Shared Pantry Item Schema
The same fields Food Tracker (Django) modeled with Django's ORM and Food Tracker (FastAPI) models with SQLAlchemy, expressed here as a plain SQL table:
Every design decision from the Django course's own Chapter 2 still applies exactly as reasoned there: expiry_date is nullable — NULL once an item is marked used, not deleted — and the row itself never gets removed, so the combined history list (Chapter 8) always has something to show. added_at defaults to the current time at insert, mirroring Django's own auto_now_add — the database itself stamps it, so the client never has to be trusted to supply an honest timestamp.
Why No ORM Here — A Deliberate, Honest Choice
Python has one dominant default in each of this quartet's other two backend courses — Django's own built-in ORM, and SQLAlchemy for FastAPI. Node's own ecosystem has no single equivalent default; several exist (Prisma, Sequelize, Drizzle, Knex), each with real adoption but none as close to "the obvious choice" as Django's own ORM is for Django. Rather than pick one somewhat arbitrarily, this course uses better-sqlite3 directly — a genuine SQL library, not an ORM at all — and writes real SQL by hand throughout.
makemigrations/migrate) tracks every model change as a versioned, reversible file. This course has none of that — changing the schema later means hand-writing an ALTER TABLE statement and running it manually, with no built-in history of what changed or when. For a single-table app at this course's own realistic scale, that's a reasonable tradeoff; it stops being one the moment the schema grows large or the team grows past one person.
better-sqlite3 has no async/await at all — db.prepare(sql).get() returns its result immediately, blocking the event loop for that one query. This is a real, intentional departure from Node's usual async-everything convention, justified by SQLite's own local-file nature (no network round-trip to wait on) and by the library's own measured performance advantage over async alternatives for this exact use case.
Express Route Structure
Routes live in their own module, mounted under a common prefix — the pattern every later chapter's own routes build on:
Note the parameterized ? placeholders in the INSERT — better-sqlite3 handles escaping automatically, the same real protection against SQL injection that an ORM would otherwise provide implicitly. Skipping an ORM does not mean skipping this protection; it just means it's the developer's own responsibility to always use placeholders rather than string-concatenating values into a query.
Where This Course Is Headed
Barcode lookup next, then the camera-scanning React component (shared almost verbatim with Food Tracker (React + Firebase)), building on this chapter's own routing pattern for every remaining feature.
Hands-On Exercises
Explain why this course uses better-sqlite3 directly instead of an ORM, and name the one concrete capability this choice gives up compared to Django's own migration system.
📄 View solutionExplain what makes better-sqlite3 unusual among Node database libraries, and why that unusual choice is still justified for this specific app.
📄 View solutionExplain why the ? placeholders in the INSERT statement matter for security, and what would happen if a value were string-concatenated into the SQL directly instead.
📄 View solutionChapter 2 Quick Reference
- Schema: id, name, barcode, category, expiry_date (nullable), status, added_at, used_at — same shape as every sibling course
- No ORM: better-sqlite3, real SQL written by hand, deliberately (Node has no single dominant ORM the way Django does)
- Real cost: no migrations system — schema changes are manual ALTER TABLE statements
- Deliberately synchronous: better-sqlite3 has no async/await, unlike most Node DB drivers
- Routes: Router-per-resource, mounted under /api/items — the pattern every later chapter reuses
- Security: parameterized ? placeholders prevent SQL injection, the same as an ORM would
- Next chapter: Barcode Lookup: Integrating Open Food Facts
Barcode Lookup: Integrating Open Food Facts
Food Tracker (React + Express)
Chapter 3 · Barcode Lookup: Integrating Open Food Facts
Every course in this quartet integrates Open Food Facts the same way in spirit: through a server the client trusts, not directly from the browser. Here, that server is the same Express process built in Chapter 2 — one more route, reusing the exact same db connection.
Why Proxy Through the Server At All
Open Food Facts needs no API key — there's no secret to hide, so this isn't about security the way an authenticated third-party API would demand. The real reasons to route the lookup through Express rather than calling Open Food Facts directly from React are the same ones the Django and Firebase courses reasoned through in their own Chapter 3/4:
- Caching. The same barcode gets scanned repeatedly over time — caching the result server-side avoids re-querying Open Food Facts for a product this app has already looked up.
- Consistency. Every client — this React app today, a future mobile app tomorrow — gets identical lookup behavior, defined in one place.
- Future-proofing. If Open Food Facts' own API shape ever changes, or a second data source gets added later, only the server needs to change.
Extending the Schema: A Barcode Cache
Reusing Chapter 2's own schema.sql file and db.js connection, one more table:
barcode is the primary key here — deliberately different from Chapter 2's own items table, where the same barcode can legitimately appear on many separate rows (many separate purchases of the same product over time). One barcode maps to exactly one cached product lookup, but potentially many pantry items.
The Lookup Route
fetch is a genuine Node.js global as of Node 18 — no axios or node-fetch dependency required to call an external API from the server. The exact same fetch API a browser already knows now works identically on the server side, one more small piece of "the same language, both ends" this course keeps returning to.
data.status === 1 only means "a product exists for this barcode," not "this product has complete data." The route above already returns null for missing fields rather than throwing — Chapter 5's own add-item form has to be built expecting that, with a manual-entry fallback for whatever the lookup didn't provide.
requests; Firebase's Cloud Function called fetch in an isolated serverless context. Here, the exact same integration lives as one more route in the same Express app already serving /api/items — no new deployment target, no new runtime, just another file mounted the same way Chapter 2 established.
Where This Course Is Headed
The camera-scanning React component next — shared almost verbatim with Food Tracker (React + Firebase), since decoding a barcode client-side has nothing to do with which backend receives it afterward.
Hands-On Exercises
Explain the three reasons this chapter gives for proxying the Open Food Facts lookup through Express rather than calling it directly from React, given that no API key is involved.
📄 View solutionExplain why barcode is the primary key in barcode_cache but not in the items table, even though both tables have a barcode column.
📄 View solutionExplain why data.status === 1 is not the same guarantee as "this product has complete data," and what the lookup route does about that in practice.
📄 View solutionChapter 3 Quick Reference
- Why proxy: caching, consistency across clients, future-proofing — not secrecy (no API key needed)
- New table: barcode_cache, keyed by barcode (one lookup per barcode, unlike items)
- Route: GET /api/lookup/:barcode — checks the cache first, else calls Open Food Facts and caches the result
- Node's built-in fetch: no axios/node-fetch dependency needed since Node 18
- Real gotcha: Open Food Facts data is crowdsourced and often incomplete — null fields are expected, not an error
- Next chapter: Camera-Based Barcode Scanning in React
Camera-Based Barcode Scanning in React
Food Tracker (React + Express)
Chapter 4 · Camera-Based Barcode Scanning in React
This chapter builds the piece of the app that feeds Chapter 3's Express route a barcode in the first place — and it's the one chapter in this entire course with no Express, no Node, and no server involvement at all. Everything here runs in the browser, which is exactly why this exact component was already built once before: Food Tracker (React + Firebase)'s own Chapter 4 built it first, and what follows is that same code, unchanged.
Requesting Camera Access
facingMode: "environment" requests the rear camera on a phone — the one actually useful for scanning a product. getUserMedia also only works over HTTPS (or localhost during development) — worth knowing now, and something to check specifically once Chapter 11 deploys this app for real, since an Express deployment doesn't get HTTPS by default the automatic way Firebase Hosting does.
Decoding Barcodes From Video Frames
Two genuinely different approaches exist. The native browser BarcodeDetector API is fast and built in — but as of general browser support, it's available in Chrome/Edge on Android and desktop, and not in Safari on iOS. A JS library like ZXing (@zxing/browser) works everywhere, at some added CPU cost, since it decodes frames in pure JavaScript rather than using a native implementation. The practical pattern: try BarcodeDetector where it exists, fall back to ZXing where it doesn't.
Note what this hook does not import, call, or reference anywhere: no fetch to a specific URL, no backend SDK, nothing at all. It only calls one thing — onDetected(barcode) — a plain callback prop the parent supplies. That single design decision is exactly what makes reusing it unchanged possible.
Wiring It to This Course's Own Backend
The only place this course's own identity shows up is in the parent component's callback — everything above this point is identical, character for character, to what Food Tracker (React + Firebase)'s own Chapter 4 already built:
Compare this to Food Tracker (React + Firebase)'s own handleDetected, which called a Cloud Function (lookupBarcode({ barcode })) instead of a plain fetch. That one line is the entire difference between the two courses' use of this component — a callable Cloud Function on one side, a REST fetch call on the other, both feeding an identical scanning hook.
"BarcodeDetector" in window being false doesn't throw an error; it just quietly does nothing unless the fallback path is genuinely built and tested, not just sketched in a comment.
Where This Course Is Headed
The add-item flow next — a React form component plus an Express POST endpoint, taking this scanned barcode's lookup result the rest of the way into the database, with client- and server-side validation both covered honestly.
Hands-On Exercises
Explain exactly what changed and what stayed identical between this chapter's ScanScreen and Food Tracker (React + Firebase)'s own version of it. Why does so little actually need to change?
📄 View solutionExplain what happens if the useEffect cleanup function omits stream?.getTracks().forEach(track => track.stop()), and why this matters specifically for a scan screen a user might navigate to and away from repeatedly.
📄 View solutionExplain why leaving the ZXing fallback as an unimplemented stub could pass all of a developer's own testing and still be a real bug in production. Which users specifically would be affected, and why wouldn't Chrome-based testing ever catch it?
📄 View solutionChapter 4 Quick Reference
- This component is reused, not rebuilt — identical to Food Tracker (React + Firebase) Chapter 4's own useBarcodeScanner hook
facingMode: "environment"— the rear camera, not the front-facing oneBarcodeDetector— native, fast, Chrome/Edge/Android; not on Safari/iOS — needs a ZXing fallback- Cleanup — always stop every track from the stream in the effect's cleanup function, or the camera stays on after unmount
- The one line that differs: handleDetected calls fetch("/api/lookup/...") here, vs. a Cloud Function in the Firebase course
- Next chapter: Building the Add-Item Flow
Building the Add-Item Flow
Food Tracker (React + Express)
Chapter 5 · Building the Add-Item Flow
Chapter 4's scanner hands off a barcode lookup result — or nothing at all, if the scan misses or the product isn't in Open Food Facts. Either way, this chapter is where that result actually becomes a row in the items table Chapter 2 defined.
The Form Component
Pre-filled where Chapter 3's lookup provided data, editable everywhere, and fully usable even with nothing pre-filled at all — a genuine manual-entry fallback, not an afterthought:
expiryDate || null matters here specifically: an empty string is not the same value as a missing date, and Chapter 2's schema expects NULL, not an empty string, whenever no expiry date applies.
The Client-Side Check, and Why It's Not Enough on Its Own
if (!name.trim()) above catches an empty name instantly, before any network request — real, useful UX, giving immediate feedback with no round-trip delay. But it's running entirely inside code the browser executes, which means it's also code a user (or a malicious script, or a stray curl command) can simply never run at all. Nothing about the client-side check stops a POST request built by hand from reaching the server with no name field whatsoever.
The Real Gate: Server-Side Validation
Chapter 3's POST route, updated to actually check what it receives before touching the database:
req.body came from outside this process, regardless of which client sent it or how carefully that client's own form was built. A missing field, a wrong type, or a deliberately malformed request are all real possibilities the server must check for itself — the client-side check earlier in this chapter exists purely to make the honest, well-behaved case pleasant; it does no security work whatsoever.
initialData being {} the whole way through, with the user typing every field by hand, needs to be a genuinely first-class path through this form, not something only handled if there happens to be time for it.
Where This Course Is Headed
Expiry alerts next — an Express endpoint querying for items nearing their expiry date, paired with a React dashboard component.
Hands-On Exercises
Explain why the client-side name check in AddItemForm provides no real security, even though it correctly prevents an empty name from being submitted through the form's own UI.
📄 View solutionExplain the finding-box's own claim: why did Food Tracker (React + Firebase) need to add Security Rules specifically to get real write validation, while this course's Express route already provides it "by construction"?
📄 View solutionExplain why expiryDate || null matters in the form's submit handler — what would go wrong if an empty string were sent to the server instead of null when no date is entered?
📄 View solutionChapter 5 Quick Reference
- AddItemForm: pre-filled from Chapter 4's scan result, fully usable with nothing pre-filled (the manual-entry fallback)
- Client-side validation: real UX value (instant feedback), zero security value (trivially bypassable)
- Server-side validation: the actual gate — checks name presence/type and expiry_date validity before touching the database
- This course's own architectural advantage: every write already passes through Express by construction — no separate Security Rules layer needed, unlike the Firebase sibling
- Gotcha: an empty string and a missing value are not the same thing — always normalize to null before hitting the database
- Next chapter: Expiry Alerts
Expiry Alerts
Food Tracker (React + Express)
Chapter 6 · Expiry Alerts
Every item added in Chapter 5 now has a real row, with a real (or null) expiry_date. This chapter turns that stored date into something actually useful: a list of what needs to be used soon.
The Alerts Route
status = 'active' excludes anything already marked used (Chapter 9's own territory); expiry_date IS NOT NULL excludes items with no expiry date at all — both conditions matter, since a used item retains its historical row but no longer has a live expiry date to alert on. date('now', '+3 days') is SQLite's own built-in date arithmetic, computing "three days from today" directly inside the query.
expiry_date is a TEXT column, and <= between two text values compares them lexicographically (character by character), not chronologically. This happens to give the correct chronological result here only because every date in this schema is consistently stored as YYYY-MM-DD — a format where lexicographic order and chronological order agree. Storing even one date in a different format (MM/DD/YYYY, for instance) would silently break every comparison in this route, with no error at all — just wrong results.
WHERE status = ..., ORDER BY expiry_date). SQLite has no such requirement: a query like this one runs correctly with no index declared at all, and at this app's own realistic scale (a single household's pantry, at most a few hundred rows), a full table scan on every request is genuinely fast enough that adding an index wouldn't even be noticeable. This isn't SQL being "better" in general — Firestore's index requirement exists for good reasons at Firestore's own intended scale — but at this specific app's own size, it's a real, fair point in this course's favor, named honestly rather than glossed over.
A Custom Hook for the Dashboard
refresh is exposed deliberately, not just called once internally — Chapter 9's own "mark used" action needs a way to trigger a fresh alerts fetch immediately afterward, since an item marked used should disappear from this list right away, not just on the next full page reload.
The Dashboard Component
'+3 days' is hardcoded directly in the SQL string above for clarity in this chapter, but a real app should pull that number from one shared constant (an environment variable, or a config file) rather than repeating the literal string anywhere the query might be duplicated later — the same "define it once" discipline this course already applied to the accent color and schema definitions.
Where This Course Is Headed
Item history and live search-as-you-type next — a debounced search built as a custom React hook, hitting a new Express search endpoint.
Hands-On Exercises
Explain why the alerts query's date comparison only works correctly because every date in this schema is stored in YYYY-MM-DD format, and what would happen if one date were stored in a different format.
📄 View solutionExplain why this course's alerts query needs no composite index while the equivalent Firestore query in Food Tracker (React + Firebase) does, and why this isn't simply "SQL is better than Firestore" in general.
📄 View solutionExplain why useExpiryAlerts exposes its own refresh function rather than only fetching once internally on mount.
📄 View solutionChapter 6 Quick Reference
- Route: GET /api/items/alerts — status='active' AND expiry_date IS NOT NULL AND expiry_date <= date('now', '+3 days')
- Real gotcha: SQLite text-date comparison only works because every date uses YYYY-MM-DD consistently
- Fair SQL advantage: no composite index needed at this app's realistic scale, unlike the Firebase sibling's equivalent query
- useExpiryAlerts: a custom hook exposing alerts, loading, and a callable refresh (needed by Chapter 9's own mark-used action)
- Next chapter: Item History & Live Search-as-You-Type
Item History & Live Search-as-You-Type
Food Tracker (React + Express)
Chapter 7 · Item History & Live Search-as-You-Type
Every item ever added stays in the items table forever — Chapter 2's own design decision, active or used. This chapter surfaces that whole history, searchable in real time as the user types.
The Search Route
Deliberately no status filter — unlike Chapter 6's own alerts query, this route searches the entire history, active and used items both, since re-adding something bought before is exactly the case this search exists for. '%' || ? || '%' wraps the parameter in SQL wildcards while still keeping it fully parameterized — the same SQL-injection protection from Chapter 2 applies here without any extra effort.
nameLower, purely because Firestore has no native case-insensitive substring search at all — every item's name has to be duplicated into a second, search-friendly field just to make matching work. SQLite's own LIKE operator is case-insensitive for ASCII text by default, with no shadow field, no duplicated data, and no extra write-time bookkeeping required. This is the exact same honest advantage Food Tracker (Django) already claimed for its own icontains lookup — a genuine, real SQL win, not specific to any one framework built on top of SQL.
'%' || ? || '%') can never use a standard database index efficiently, even if one existed on name — the database has no choice but to check every row, since a match could start anywhere in the string. At this app's own realistic scale (Chapter 6's own point, repeated here), that cost is genuinely invisible. It would become a real, different problem at a scale large enough to need dedicated full-text search — a tool like SQLite's own FTS5 extension, or an external search service, neither of which this course builds.
A Reusable Debounce Hook
Firing a search request on every single keystroke would flood the server with requests for a query the user hasn't finished typing yet. Debouncing delays the actual fetch until typing pauses:
return () => clearTimeout(timeoutId), every keystroke would still schedule its own timer, and every one of those timers would eventually fire — the delay would only push the flood of requests later, not prevent it. Because useEffect re-runs this whole function on every query change, each run's cleanup cancels the previous run's still-pending timer before scheduling a new one — the same "always clean up what the last effect run started" discipline Chapter 4's camera-stream cleanup already taught, applied here to a timer instead of a media stream.
The Search Component
Where This Course Is Headed
Marking items used next — a React action and an Express PATCH endpoint, tying directly back into both this chapter's own search results and Chapter 6's own alerts dashboard.
Hands-On Exercises
Explain why this course's search route needs no shadow field the way Food Tracker (React + Firebase)'s nameLower field does, and name the SQLite feature responsible.
📄 View solutionExplain what would happen if useDebouncedSearch's useEffect omitted its cleanup function, and why the fix is described as "the same lesson" as Chapter 4's camera-stream cleanup.
📄 View solutionExplain why the search route deliberately has no status filter, unlike Chapter 6's own alerts route.
📄 View solutionChapter 7 Quick Reference
- Route: GET /api/items/search?q=... — SQL LIKE '%'||?||'%', no status filter, the full history
- Real SQL advantage: SQLite's LIKE is case-insensitive for ASCII by default — no nameLower shadow field needed, unlike the Firebase sibling
- Honest limit: a leading wildcard can't use an index — invisible at this app's scale, a real cost at a larger one
- useDebouncedSearch: a reusable hook, delay tunable, cleanup cancels the previous pending timer on every keystroke
- Same lesson as Chapter 4: always clean up what the previous effect run started
- Next chapter: Marking Items Used
Marking Items Used
Food Tracker (React + Express)
Chapter 8 · Marking Items Used
Every earlier chapter built toward this exact moment: an item is finally used up, and the row created back in Chapter 5 needs to reflect that — without ever disappearing from the history Chapter 7 searches.
The Route
Two things worth noticing in that single query. First, expiry_date = NULL — the same nullable-not-deleted design Chapter 2 committed to from the start, now paying off: the row stays in the table forever, but its expiry date genuinely goes away, exactly the way Chapter 6's own alerts query (expiry_date IS NOT NULL) already expects. Second, AND status = 'active' in the WHERE clause — the update only actually touches a row that's currently active, which means marking an already-used item "used" again is a no-op rather than silently re-stamping used_at with a new, incorrect timestamp.
GET — a browser's own link-prefetching, a crawler following every link on a page, or simply a user middle-clicking to open in a new tab could all trigger a GET request without the user ever intending to mark anything used. PATCH (or POST) requires the request to come from an explicit action — a button's onClick firing a real fetch call — never something a browser might do on its own while merely loading or navigating a page.
The React Action
refresh from useExpiryAlerts specifically for this moment, rather than only fetching once internally. Calling refresh() right after the PATCH succeeds re-runs the alerts query, and since the item's status is now 'used', it no longer matches that query's own WHERE status = 'active' condition — it disappears from the dashboard immediately, with no full page reload and no manual list-filtering logic on the frontend at all. The backend query is the single source of truth for "what's currently expiring soon"; the frontend just asks it again.
PATCH to actually succeed, then re-fetch — accepting a small, honest delay in exchange for never showing the user a state the server hasn't actually confirmed.
Where This Course Is Headed
Recipe lookup with TheMealDB next — an Express route and a React results component, matching items nearing expiry against real recipes.
Hands-On Exercises
Explain what the AND status = 'active' clause in the UPDATE statement actually prevents, and what would go wrong without it if a user managed to click "Mark Used" twice on the same item.
📄 View solutionExplain why marking an item used must never be implemented as a plain GET request, with a concrete example of how a GET-based version could be triggered unintentionally.
📄 View solutionExplain how calling refresh() after a successful markUsed request causes the item to disappear from the dashboard, tracing exactly which earlier chapter's own query condition makes that work.
📄 View solutionChapter 8 Quick Reference
- Route: PATCH /api/items/:id/use — sets status='used', used_at, and clears expiry_date to NULL
- Guard: AND status = 'active' in the WHERE clause makes re-marking an already-used item a safe no-op
- Never a GET: a state-changing action must require an explicit request, not something a browser could trigger while merely loading a page
- The payoff: refresh() from Chapter 6 re-runs the alerts query, which naturally excludes the now-used item — no manual frontend filtering needed
- Deliberate simplicity: wait-then-refresh, not optimistic UI updates
- Next chapter: Recipe Lookup with TheMealDB
Recipe Lookup with TheMealDB
Food Tracker (React + Express)
Chapter 9 · Recipe Lookup with TheMealDB
Chapter 6's alerts query already knows what's expiring soon. This chapter takes that same list and asks a second free API, TheMealDB, what could actually be cooked with it.
Extending the Schema: A Recipe Cache
The same caching pattern Chapter 3 established for barcode lookups, applied to per-ingredient recipe results:
The Suggestion Route
A meal matching three expiring ingredients ranks above one matching only one — the same relevance-by-match-count sorting every course in this quartet uses for its own recipe feature.
Promise.all fires every ingredient's lookup concurrently instead of one after another, because Node's own async model makes that the natural way to write it, not a special optimization bolted on afterward. Five expiring ingredients means five requests in flight at once here, rather than five requests run one after the other — a genuine payoff of Chapter 1's own "one language, both ends" framing, where JavaScript's async-first design turns out to matter for more than just tooling convenience.
filter.php?i= expects TheMealDB's own specific ingredient vocabulary (chicken_breast, not chicken or chicken breasts) — a real, generic pantry item name like "Trader Joe's Organic Chicken Thighs" won't match cleanly no matter how it's normalized. The .toLowerCase().replace(/\s+/g, "_") normalization above handles simple cases; it does not solve the deeper problem of a free-text product name not lining up with a curated recipe database's own fixed vocabulary. This app's own honest scope stops at "best-effort matching," not guaranteed matches for every real product name.
The React Results Component
ingredient rather than by the whole combination of expiring items means a cached "chicken" lookup gets reused the next time chicken appears in the alerts list, regardless of what else happened to be expiring alongside it that day — the same granular-caching principle as Chapter 3's own barcode_cache.
Where This Course Is Headed
State management across the whole app next — where component-local state ends and a shared approach begins, now that scanning, alerts, history, and recipes all need to talk to each other.
Hands-On Exercises
Explain the concrete difference in behavior between Promise.all(expiring.map(...)) and a for loop that awaits each lookupIngredient call one at a time, for five expiring ingredients.
📄 View solutionExplain why a generic pantry item name like "Trader Joe's Organic Chicken Thighs" might fail to match anything in TheMealDB even after normalization, and why this is described as an honest scope limit rather than a bug to fix.
📄 View solutionExplain why recipe_cache is keyed by ingredient rather than by the full combination of expiring items on any given day, and what benefit that specific choice provides.
📄 View solutionChapter 9 Quick Reference
- Route: GET /api/recipes/suggest — fans out to TheMealDB per expiring ingredient, merges and sorts by match count
- New table: recipe_cache, keyed by ingredient, storing the JSON meal list as text
- Real advantage: Promise.all fires every ingredient lookup concurrently — a genuine payoff of Node's async model, vs. Food Tracker (Django)'s own honestly-named sequential fan-out
- Real gotcha: TheMealDB expects its own exact ingredient vocabulary — generic product names often won't match cleanly even after normalization
- Next chapter: State Management Across the App
State Management Across the App
Food Tracker (React + Express)
Chapter 10 · State Management Across the App
Every feature so far has managed its own state: useExpiryAlerts, useDebouncedSearch, RecipeSuggestions' own useState. That worked fine when each feature only needed to know about itself. It stops being enough the moment one action needs to update several of them at once.
The Problem, Concretely
Chapter 8's markUsed called refresh() directly on the one hook it happened to have a reference to — useExpiryAlerts. But marking an item used should really affect three separate features at once:
- Alerts (Chapter 6) — the item should disappear, since it's no longer active.
- Search results (Chapter 7) — if the user's current search happens to include this item, its status should update to reflect "used."
- Recipe suggestions (Chapter 9) — an ingredient that's no longer expiring shouldn't keep influencing recipe matches.
Wiring markUsed to call three separate refresh functions directly works today, but it doesn't scale: every new feature that cares about item changes means going back and adding one more manual call at every single place items get mutated (add, mark used, and anything added later). That's a real, growing coordination cost, not a hypothetical one.
A Shared Change-Notification Context
Rather than wiring components directly to each other, one shared signal any component can both trigger and listen to:
version is deliberately just a number, not the actual item data — this Context coordinates when to refetch, it doesn't try to own or duplicate every feature's own data-fetching logic.
Wiring Every Consumer Through the Same Signal
Chapter 6's hook, updated to refetch whenever version changes, instead of only on mount:
And Chapter 8's markUsed, now calling the shared signal instead of one specific hook's own refresh function:
RecipeSuggestions and the search hook get the same one-line change: depend on version in their own useEffect, instead of being individually wired to whatever action happened to trigger the update.
O(features × mutations) wiring problem that only grows as the app grows. After this chapter, a mutation only needs to know about one thing: call notifyChange(). Every feature that cares about item changes subscribes to version on its own, entirely independently of whatever action happened to trigger the change. Adding a tenth feature later means that feature subscribes to version itself — it does not mean going back to add one more call inside markUsed, addItem, and everywhere else a mutation happens.
version causes every component consuming ItemsContext to re-render, even ones whose own displayed data didn't actually change as a result. At this app's own scale — a handful of features, infrequent mutations (a user adding or using an item, not hundreds of updates per second) — that's genuinely invisible. It would become a real performance concern in an app with many more Context consumers and much more frequent updates, where a more granular state library (Zustand, Jotai, or splitting into several smaller contexts) would be the more honest choice instead of one shared version counter.
Where This Course Is Headed
Deployment next — serving the built React app from the same Express process, and the environment configuration that goes with a real deployment.
Hands-On Exercises
Explain the concrete coordination problem with Chapter 8's original approach (markUsed calling refresh() directly) once a third feature, recipe suggestions, also needs to react to the same mutation.
📄 View solutionExplain why version is stored as a plain number rather than the actual items array, and what this Context is and isn't responsible for as a result.
📄 View solutionExplain the honest limit named in this chapter's own warn-box, and describe a scenario (in terms of app size or update frequency) where that limit would actually start to matter in practice.
📄 View solutionChapter 10 Quick Reference
- The problem: Chapter 8's direct refresh() call only reaches one hook — doesn't scale as more features need to react to the same mutation
- ItemsContext: holds one plain number, version, plus notifyChange() to bump it
- Every relevant hook: depends on version in its own useEffect, refetching its own data independently
- The real change: a mutation only calls notifyChange() once — it no longer needs to know which features are listening
- Honest limit: every Context consumer re-renders on every version bump — fine at this scale, a real cost at a much larger one
- Not a caching layer: each hook still fetches its own data from its own route; a library like React Query would add caching on top of this same idea
- Next chapter: Deployment
Deployment
Food Tracker (React + Express)
Chapter 11 · Deployment
Every prior chapter ran two separate processes — Vite's dev server and Express — talking across the CORS boundary Chapter 1 set up. A real deployment collapses that back down to one.
Building the Client
Serving the Build From Express
app.get("*", ...) matches every path, including /api/items. If the catch-all were registered before the API routers, every single API request would be swallowed by it, silently returning index.html instead of JSON — a real, subtle bug with no error message anywhere, just API calls that mysteriously "stop working" the moment static serving is added. The API routes must always be registered first.
Why the Catch-All Route Exists At All
React Router (or any client-side router) handles navigation entirely in the browser — a URL like /history never actually exists as a real file on the server. Without the catch-all, refreshing the page on /history would hit Express directly, find no matching route, and return a 404. app.get("*", ...) always returns index.html instead, letting React Router take over and render the correct view client-side once the page loads.
Environment Configuration and Process Management
A plain node server/index.js process exits the moment it crashes, and doesn't restart on its own. A process manager like pm2 keeps the server running, restarting it automatically on a crash or a server reboot — the Node equivalent of what a platform's own process supervisor would otherwise provide:
TLS Termination
Chapter 4's own getUserMedia requires HTTPS in production — no exception. Express itself doesn't handle TLS certificates; the standard approach is a reverse proxy (nginx, matching the pattern already covered on this site in Nginx In Depth) sitting in front of the Node process, terminating HTTPS and forwarding plain HTTP internally — the same general shape Food Tracker (Django)'s own deployment chapter used with gunicorn sitting behind a real web server, just with Node in place of gunicorn.
Where This Course Is Headed
One chapter left: a capstone tying every chapter into one complete, working app.
Hands-On Exercises
Explain exactly what would happen to a request for /api/items if the catch-all route were registered before the API routers instead of after, and why no error would appear anywhere to signal the problem.
📄 View solutionExplain why the catch-all route needs to exist at all, given that React Router already handles client-side navigation.
📄 View solutionExplain the difference between this chapter's own SQLite production concern (ephemeral storage) and Food Tracker (Django)'s own SQLite production concern (concurrency), and why each course names a different risk as the more relevant one.
📄 View solutionChapter 11 Quick Reference
- Build: npm run build produces client/dist/, served via express.static
- Order matters: API routes must be registered before the catch-all app.get("*", ...), or it swallows every API request
- The catch-all's job: returns index.html for any unmatched path, letting React Router handle client-side navigation on refresh
- Honest cost: this course hand-configures what Firebase Hosting provided automatically (SPA rewrite, HTTPS) for the Firebase sibling
- Process management: pm2 restarts the server automatically on crash or reboot
- TLS: a reverse proxy (nginx) in front of Node, the same general shape as the Django sibling's gunicorn-behind-a-web-server model
- Real gotcha: better-sqlite3's single database file needs a persistent volume — an ephemeral filesystem silently loses all data
- Next chapter: Capstone
Capstone: A Complete, Working Food Tracker
Food Tracker (React + Express)
Chapter 12 · Capstone: A Complete, Working Food Tracker
Marcus keeps his own household pantry tracked with the app this course just spent eleven chapters building — one Express process, one SQLite file, one React app, all deployed and running for real. What follows is one ordinary session with it.
Marcus scans a carton of milk. Chapter 4's useBarcodeScanner hook decodes the barcode entirely client-side, then handleDetected calls fetch("/api/lookup/...") — Chapter 3's route, which checks barcode_cache first and falls back to a live Open Food Facts call, caching whatever it finds.
Chapter 5's AddItemForm pre-fills the name and category from that lookup. Marcus sets the expiry date and submits; the client-side check catches an empty name instantly, but it's Chapter 3's own POST /api/items route — the real gate, by construction — that actually validates and inserts the row, exactly as Chapter 5's finding-box promised it would.
Chapter 6's alerts route surfaces the milk once it's within three days of its (consistently YYYY-MM-DD-formatted) expiry date — a plain SQL range query needing no index at all, at this app's own realistic scale.
Wanting to check if he's bought a particular brand of yogurt before, Marcus types "yog" into the search box. Chapter 7's useDebouncedSearch waits for him to stop typing, then hits the search route's native, case-insensitive LIKE match — no shadow field required, unlike the Firebase sibling's own nameLower.
The milk gets finished. Chapter 8's PATCH /api/items/:id/use — never a plain link — sets status='used', stamps used_at, and clears expiry_date to NULL, guarded by AND status = 'active' so accidentally clicking twice changes nothing the second time.
Marking the milk used calls Chapter 10's notifyChange() — one call, with no knowledge of who's listening. The alerts dashboard disappears the milk from its own list; the recipe suggestions (Chapter 9) stop counting it as an expiring ingredient — both react on their own, independently, exactly as Chapter 10's whole redesign was built to make possible.
With chicken and spinach both nearing expiry, Chapter 9's Promise.all fan-out queries TheMealDB for both concurrently — not sequentially, a real, honest advantage over the Django sibling's own admitted sequential cost — merging and sorting the results by how many expiring ingredients each recipe actually uses.
Marcus's session happens against a real deployment: Express serving the built React app via Chapter 11's own catch-all SPA route (registered after the API routes, never before), pm2 keeping the process alive, nginx terminating HTTPS in front of it, and the SQLite file sitting on a volume that survives a redeploy.
Chapter Attribution
| Step | Chapter(s) applied |
|---|---|
| 1 — Scanning | Chapter 4 (camera scanning), Chapter 3 (lookup route + barcode_cache) |
| 2 — Adding the item | Chapter 5 (AddItemForm, server-side validation), Chapter 2 (schema, INSERT) |
| 3 — Expiry alert | Chapter 6 (date range query, YYYY-MM-DD discipline) |
| 4 — Search | Chapter 7 (debounce hook, native LIKE) |
| 5 — Marking used | Chapter 8 (PATCH route, active-only guard, expiry_date=NULL) |
| 6 — Automatic updates | Chapter 10 (ItemsContext, notifyChange, version) |
| 7 — Recipe suggestion | Chapter 9 (Promise.all fan-out, recipe_cache, relevance sort) |
| 8 — Real deployment | Chapter 11 (static serving, SPA fallback, pm2, TLS, persistent volume) |
Promise.all turning JavaScript's async-first design into a genuine performance advantage, and Chapter 10's Context-based coordination solving a real cross-feature problem with nothing more than React's own built-in tools. None of these payoffs required abandoning the honest limits named along the way — no ORM, no shadow-field-free search that scales infinitely, no free HTTPS — this course's own throughline was never "JavaScript solves everything," just "the same language, both ends, is a real and specific advantage where it actually applies."
Hands-On Exercises
Trace Step 6 in detail: explain exactly what markUsed calls, and how that one call results in both the alerts dashboard and the recipe suggestions updating, without either being called directly.
📄 View solutionPick any two steps from Marcus's session and explain how each one depends on at least two earlier chapters working together, not just one chapter in isolation.
📄 View solutionExplain why this course's honest scope note names the same missing multi-user ownership model as Food Tracker (Django)'s own capstone, while explicitly contrasting that against Food Tracker (React + Firebase), which did build real per-user authentication.
📄 View solutionChapter 12 Quick Reference — Course Complete
- 8 steps, 11 prior chapters — one continuous, realistic session with the finished, deployed app
- This course's own throughline, closed out: the same language, both ends, is a real, specific advantage — not a universal solution
- Honest scope note: no meal planner, no offline/PWA, no multi-user ownership model (same gap as the Django sibling), TheMealDB matching remains best-effort, no automated tests/CI
- Food Tracker (React + Express) is now complete — 12/12 chapters