Building the Add-Item Flow

Food Tracker (React + Express + Prisma)

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 via Chapter 2's Item model. The form itself is identical in spirit to the sibling course's own; what genuinely changes is what happens once a submission reaches the server.

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:

import { useState } from "react"; function AddItemForm({ initialData = {}, onSaved }) { const [name, setName] = useState(initialData.name || ""); const [category, setCategory] = useState(initialData.category || ""); const [expiryDate, setExpiryDate] = useState(""); const [barcode] = useState(initialData.barcode || null); const [error, setError] = useState(null); const handleSubmit = async (e) => { e.preventDefault(); setError(null); if (!name.trim()) { setError("Item name is required."); return; } const response = await fetch("/api/items", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, barcode, category, expiryDate: expiryDate || null }), }); if (!response.ok) { const data = await response.json(); setError(data.error || "Something went wrong."); return; } onSaved(await response.json()); }; return ( <form onSubmit={handleSubmit}> <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Item name" /> <input value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Category" /> <input type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} /> {error && <p className="form-error">{error}</p>} <button type="submit">Add Item</button> </form> ); }
A small, concrete payoff of Chapter 2's own naming choice
Look closely at the request body: { name, barcode, category, expiryDate: expiryDate || null } — the exact same field name, expiryDate, all the way from the React state variable to the JSON key to the Prisma field it lands in. The sibling course's own version of this same line has to write expiry_date: expiryDate || null — translating its camelCase React state into the snake_case key its raw-SQL backend expects. That translation step is small, but it's a real extra thing to get right on every field, every time, and this course simply doesn't have it, purely as a side effect of Prisma's own camelCase convention matching JavaScript's own.

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:

// routes/items.js router.post("/", async (req, res) => { const { name, barcode, category, expiryDate } = req.body; if (!name || typeof name !== "string" || !name.trim()) { return res.status(400).json({ error: "name is required" }); } if (expiryDate && isNaN(Date.parse(expiryDate))) { return res.status(400).json({ error: "expiryDate is not a valid date" }); } const item = await prisma.item.create({ data: { name: name.trim(), barcode: barcode || null, category: category || null, expiryDate: expiryDate ? new Date(expiryDate) : null } }); res.status(201).json(item); });

Two differences from the sibling's own version of this route worth naming directly. First, the explicit Date.parse check still runs here — Prisma's own type system does add a real backstop underneath it (passing a genuinely malformed value into a DateTime field raises a real validation error from Prisma Client itself), but that error has no useful HTTP status or message attached to it by default, so the explicit check stays because it produces a clean 400 response the raw Prisma error wouldn't. Second, the response is just res.status(201).json(item) — the whole created row, returned automatically by create(), rather than a response object assembled field by field.

What the sibling's own manually-assembled response actually misses
The sibling course's POST handler responds with { id: result.lastInsertRowid, name, barcode, category, expiry_date } — built by hand from the variables already in scope. Look at what's missing: status and added_at are both real columns on every item, and neither one makes it into that response, simply because nobody added them to the object literal. This course's res.status(201).json(item) can't have that specific bug — the whole row Prisma just created is what gets sent back, automatically, whether or not every field was explicitly remembered.
Why this app already has a real gate, by construction
Food Tracker (React + Firebase) had to introduce Security Rules specifically because its React client writes directly to Firestore — with no server code sitting in between by default. Neither this course nor its raw-SQL sibling ever had that gap: every write already passes through an Express route, because that's simply how this architecture works, regardless of what's on the other side of that route's own database call.
Never trust req.body
Anything arriving in 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.
The lookup-miss case is not an edge case
A meaningful share of real barcodes won't resolve to a name at all (Chapter 3's own honest note about Open Food Facts' inconsistent data) — 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 — a Prisma query using gte/lte against the real DateTime field this course has had since Chapter 2, paired with a React dashboard component.

Hands-On Exercises

Exercise 1

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 solution
Exercise 2

Using this chapter's own finding-box, explain exactly what's missing from the sibling course's manually-assembled POST response, and why this course's res.status(201).json(item) can't have the same bug.

📄 View solution
Exercise 3

Explain why the explicit isNaN(Date.parse(expiryDate)) check still belongs in this route even though Prisma's own type system would reject a genuinely malformed date value on its own.

📄 View solution

Chapter 5 Quick Reference

  • AddItemForm: pre-filled from Chapter 4's scan result, fully usable with nothing pre-filled (the manual-entry fallback)
  • No field-name translation: expiryDate is the same name in React state, the JSON body, and the Prisma field — unlike the sibling's camelCase-to-snake_case step
  • Client-side validation: real UX value (instant feedback), zero security value (trivially bypassable)
  • Server-side validation: the actual gate — an explicit check still needed for a clean 400, even with Prisma's own type system underneath it
  • Real correctness win: create()'s full-row response can't accidentally omit a field the way the sibling's hand-assembled response does (it's missing status and added_at)
  • This course's own architectural advantage, shared with the sibling: every write already passes through Express by construction — no separate Security Rules layer needed, unlike the Firebase sibling
  • Next chapter: Expiry Alerts