The Add-Item Form

Personal Catalogue: React, Express & MongoDB

Chapter 4 · The Add-Item Form

Chapter 3's own POST /api/items route already knows how to create any of the four item types — it just needs a real request body naming one. This chapter builds the one React form that actually produces that body: a single component that changes shape depending on which item type is selected, rather than four separate forms wired to four separate endpoints.

A Field Configuration Object, Not Four Separate Forms

Every item type needs different inputs, but "different inputs" doesn't have to mean "different components." A single declarative object, matched field-for-field against Chapter 2's own four discriminator schemas, is enough to drive one shared rendering loop:

TypeBackend schema field (Ch.2)Frontend config field
Bookauthor (required)author, marked required
isbnisbn
publisherpublisher
tags: [String]tags, comma-separated text → array
Cdartist (required)artist, marked required
tracklist: [String]tracklist, comma-separated text → array
Dvddirectordirector
runtimeMinutes: NumberruntimeMinutes, numeric input
regionCoderegionCode
Bluraydirectordirector
runtimeMinutes: NumberruntimeMinutes, numeric input
resolution: enum('1080p','4K')resolution, a select with those two options
// src/fieldConfig.js export const ITEM_TYPES = ['Book', 'Cd', 'Dvd', 'Bluray']; export const FIELD_CONFIG = { Book: [ { name: 'author', label: 'Author', required: true }, { name: 'isbn', label: 'ISBN' }, { name: 'publisher', label: 'Publisher' }, { name: 'tags', label: 'Tags (comma-separated)', isArray: true }, ], Cd: [ { name: 'artist', label: 'Artist', required: true }, { name: 'tracklist', label: 'Tracklist (comma-separated)', isArray: true }, ], Dvd: [ { name: 'director', label: 'Director' }, { name: 'runtimeMinutes', label: 'Runtime (minutes)', type: 'number' }, { name: 'regionCode', label: 'Region Code' }, ], Bluray: [ { name: 'director', label: 'Director' }, { name: 'runtimeMinutes', label: 'Runtime (minutes)', type: 'number' }, { name: 'resolution', label: 'Resolution', options: ['1080p', '4K'] }, ], };
One Rendering Loop, Not a Switch Statement Per Field
Every input on the form — regardless of type — is rendered by walking FIELD_CONFIG[itemType] and picking a text input, a number input, or a select based on that one field's own options/type properties. Adding a fifth item type later would mean adding one more entry to this object and one more discriminator on the backend — not a fifth JSX form.

The Type Selector & Shared Fields

title, releaseYear, and notes live on Chapter 2's own base Item schema, so every item type shares them regardless of which type is currently selected. They get their own piece of state, separate from whatever type-specific fields are showing:

// src/components/AddItemForm.jsx import { useState } from 'react'; import { ITEM_TYPES, FIELD_CONFIG } from '../fieldConfig'; export default function AddItemForm({ onCreated }) { const [itemType, setItemType] = useState('Book'); const [shared, setShared] = useState({ title: '', releaseYear: '', notes: '' }); const [typeFields, setTypeFields] = useState({}); // handleTypeChange and handleSubmit follow below }

Rendering Type-Specific Fields From the Config

The dynamic portion of the form is a single .map() over whichever config array matches the currently selected type — a select for a field with options, otherwise a text or number input:

// inside AddItemForm's return(), one input or select per config entry {FIELD_CONFIG[itemType].map((field) => ( <label key={field.name}> {field.label} {field.options ? ( <select value={typeFields[field.name] || ''} onChange={(e) => handleTypeFieldChange(field.name, e.target.value)} > <option value="">Select…</option> {field.options.map((opt) => <option key={opt} value={opt}>{opt}</option>)} </select> ) : ( <input type={field.type || 'text'} required={field.required} value={typeFields[field.name] || ''} onChange={(e) => handleTypeFieldChange(field.name, e.target.value)} /> )} </label> ))}
// AddItemForm.jsx (continued) function handleTypeFieldChange(name, value) { setTypeFields((prev) => ({ ...prev, [name]: value })); }

A Real Client-State Bug: Leftover Fields From a Previous Type

typeFields is one shared object holding whichever fields the currently selected type happens to use. Nothing shown so far ever clears it when itemType itself changes — and that gap produces a real, reproducible problem. Select Book, type an author, then switch the dropdown to Cd without touching anything else:

  1. Book is selected. typeFields becomes { author: 'Ursula K. Le Guin' }.
  2. The dropdown changes to Cd. typeFields is untouched — it's still { author: 'Ursula K. Le Guin' }, even though no author field is rendered anymore.
  3. An artist value is typed. typeFields is now { author: 'Ursula K. Le Guin', artist: 'Kraftwerk' }.
  4. The form submits with itemType: 'Cd' and both fields in the outgoing body.

Chapter 3's own POST /api/items route resolves MODEL_MAP.Cd and calls Cd.create(req.body) against that full body — the one with the leftover author field still attached.

Confirmed Directly: Mongoose's Own Strict Mode Catches It Anyway
Posting that exact leftover-author body against the real /api/items route and then inspecting the saved document (via GET /api/items/<id> or directly in mongosh) shows a clean Cd document — itemType, title, artist — with no author field anywhere on it. Chapter 2's discriminatorKey options object never set strict: false, so Mongoose's own default (strict: true) applies on writes exactly the way it already shaped Chapter 2's reads: any field passed to .create() that isn't part of the target schema is silently dropped before the document is ever saved — no error, no partial write, just quietly ignored.

So the data itself was never really at risk. The real problem is on screen, not in the database: switching back to Book a moment later would show that stale author text sitting in the input again, as if it had never left — a confusing, unreliable form, even though every actual save has stayed correct the whole time.

The Fix: Resetting Type-Specific State on Type Change

The fix is one line, in the handler that already runs every time the dropdown changes:

// AddItemForm.jsx (continued) function handleTypeChange(e) { setItemType(e.target.value); setTypeFields({}); // clear leftover fields from whatever type was selected before }
A UX Fix, Not a Correctness Fix
Nothing about this reset changes what actually gets saved — Mongoose's own strict mode was already handling that correctly before this line existed. What it fixes is trust: without it, a form field can display data that will never actually be sent anywhere once a different type is selected, which is exactly the kind of small inconsistency a real user notices and stops trusting the form over.

Converting Comma-Separated Input Into Real Arrays

Chapter 2's own tags: [String] and tracklist: [String] fields expect real arrays, but a plain text input can only ever produce one string. A comma-separated field is split, trimmed, and filtered right before the request goes out, not stored as an array in state the whole time — keeping every text input, including these two, genuinely just a plain string while it's being typed:

// AddItemForm.jsx (continued) — inside handleSubmit, before the request const payload = { itemType, ...shared, ...typeFields }; for (const field of FIELD_CONFIG[itemType]) { if (field.isArray && typeof payload[field.name] === 'string') { payload[field.name] = payload[field.name] .split(',') .map((s) => s.trim()) .filter(Boolean); } }

Typing Python, Web Development, Programming into the Tags field becomes ['Python', 'Web Development', 'Programming'] in the payload — exactly the shape Book's own schema expects, with no trailing empty entries even if a stray comma or extra space slips into what was typed.

Submitting the Form

releaseYear gets the same real-vs-string treatment runtimeMinutes needs — both are typed as strings by the DOM regardless of input type, but Mongoose's own Number SchemaType casts a valid numeric string automatically on write, confirmed directly by checking a saved document's own typeof releaseYear in mongosh and finding a real number, not a string. Converting explicitly here is still worth doing, so an empty field submits as genuinely absent rather than as an empty string Mongoose would otherwise reject:

// AddItemForm.jsx (continued) async function handleSubmit(e) { e.preventDefault(); const payload = { itemType, title: shared.title, releaseYear: shared.releaseYear ? Number(shared.releaseYear) : undefined, notes: shared.notes || undefined, ...typeFields, }; for (const field of FIELD_CONFIG[itemType]) { if (field.isArray && typeof payload[field.name] === 'string') { payload[field.name] = payload[field.name].split(',').map((s) => s.trim()).filter(Boolean); } } const res = await fetch('http://localhost:4000/api/items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) { const { error } = await res.json(); alert(error); return; } const created = await res.json(); onCreated(created); setShared({ title: '', releaseYear: '', notes: '' }); setTypeFields({}); }

A failed request surfaces exactly the message Chapter 3's own error handling produces — a real Mongoose ValidationError message for a missing required field like author, or the "Unknown itemType" message for anything not in MODEL_MAP.

Trying It in the Browser

With both the Express API (Chapter 1, port 4000) and the Vite dev server running, mounting <AddItemForm onCreated={(item) => console.log('Created:', item)} /> in App.jsx is enough to test all four types end to end: select each type in turn, fill in its own fields, and confirm the logged item in the browser console — then cross-check with curl http://localhost:4000/api/items to confirm the same four documents exist server-side, with genuinely different fields on each one. Chapter 7 replaces that console log with the real catalogue list this form is actually populating.

Hands-On Exercises

Exercise 1

Build the full AddItemForm component with FIELD_CONFIG covering all four types, wire it into App.jsx, and create one real item of each type through the actual UI, confirming each one via GET /api/items.

📄 View solution
Exercise 2

Temporarily remove the setTypeFields({}) reset from handleTypeChange, reproduce the leftover-field bug (fill a Book's author, switch to Cd, fill artist, submit), and confirm via a direct GET /api/items/:id that the saved Cd document has no author field despite it being present in the outgoing request body. Explain why.

📄 View solution
Exercise 3

The required flags on the Book and Cd form fields (author, artist) only produce a browser-level warning if left empty. Explain why this doesn't replace the backend's own required: true validation from Chapter 2, and describe a real request that would bypass the frontend's check entirely.

📄 View solution

Chapter 4 Quick Reference

  • FIELD_CONFIG — one declarative object, matched field-for-field against Chapter 2's own discriminator schemas, driving a single rendering loop instead of four separate forms
  • Shared vs. type-specific state — title/releaseYear/notes live in one piece of state; author/artist/director/etc. live in another, reset whenever itemType changes
  • The leftover-field bug — switching types without resetting typeFields lets a stale field ride along in the request body; Mongoose's default strict mode silently drops it server-side regardless
  • Arrays from text — comma-separated input is split/trimmed/filtered into a real array immediately before the request, never stored as an array in state
  • Numbers from text — releaseYear is explicitly converted with Number(); runtimeMinutes would actually be cast correctly by Mongoose even as a string, verified directly against a saved document's own typeof
  • Next chapter: Tags — storing, attaching, and filtering by tag on books specifically