The Add-Item Form: Type-Specific Fields, Client-Side Validation

Personal Catalogue: React & Firebase

Chapter 4 · The Add-Item Form: Type-Specific Fields, Client-Side Validation

Chapter 3 finished the plumbing: a validateItem() call, then a direct Firestore write, with real Security Rules standing behind both. This chapter builds the one form that actually produces the data going into that pipeline — and finds two places where skipping a step that was purely cosmetic for the MongoDB sibling would leave this app with a genuinely broken document.

The Same Field Configuration, Reused Directly

Personal Catalogue (React, Express & MongoDB)'s own Chapter 4 built a declarative FIELD_CONFIG object, matched field-for-field against its Mongoose discriminator schemas, to drive one shared rendering loop instead of four separate forms. Nothing about that idea is MongoDB-specific — it's reused here verbatim, matched instead against this course's own Chapter 2 field list:

TypeFirestore field (Ch.2)Frontend config field
Bookauthor (required)author, marked required
isbnisbn
publisherpublisher
tags (array)tags, comma-separated text → array
Cdartist (required)artist, marked required
tracklist (array)tracklist, comma-separated text → array
Dvddirectordirector
runtimeMinutes (number)runtimeMinutes, numeric input
regionCoderegionCode
Bluraydirectordirector
runtimeMinutes (number)runtimeMinutes, numeric input
resolution ('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'] }, ], };

Shared State, Type-Specific State

title, releaseYear, and notes are the base fields every item type shares, so they get their own piece of state, kept separate from whichever type-specific fields the FIELD_CONFIG lookup happens to be rendering right now:

// src/components/AddItemForm.jsx import { useState } from 'react'; import { collection, addDoc } from "firebase/firestore"; import { db } from "../firebase"; import { validateItem } from "../validateItem"; 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({}); const [error, setError] = useState(null); // handleTypeChange, handleTypeFieldChange, and handleSubmit follow below }

The type-specific inputs render the same way the MongoDB sibling's own form does — one .map() over FIELD_CONFIG[itemType], choosing a select for a field with options and a text or number input otherwise. Nothing about that rendering logic is backend-specific, so it isn't repeated here.

The Leftover-Field Bug, Reproduced — and Not Auto-Corrected

typeFields is one shared object holding whichever fields the current type happens to use, and nothing clears it when itemType itself changes. Select Book, type an author, then switch the dropdown to Cd:

  1. Book is selected. typeFields becomes { author: 'Ursula K. Le Guin' }.
  2. The dropdown changes to Cd. typeFields is untouched — 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 document.

The MongoDB sibling's own version of this exact bug turned out to be harmless in practice — Mongoose's default strict mode silently drops any field a document's own schema doesn't declare, so the leftover author never actually reached storage. Checking the equivalent here, directly:

await addDoc(collection(db, "items"), { itemType: "Cd", title: "Discovery", artist: "Daft Punk", author: "Ursula K. Le Guin", // leftover from Book, never cleared });
Confirmed Directly: Firestore Keeps It
Fetching that document back out shows itemType, title, artist, and a real, permanently stored author field that has no business being on a Cd at all. Neither validateItem() nor the Chapter 3 Security Rules ever check for the absence of unexpected fields — they only check that required fields are present. Firestore has no concept of a document's own schema rejecting a field it doesn't recognize, because it has no concept of a document schema at all. The stale field isn't a cosmetic display glitch here — it's a real, saved data-quality bug.

The Fix — Now a Correctness Fix, Not Just a UX One

The fix itself is identical to the MongoDB sibling's own one-liner:

// AddItemForm.jsx (continued) function handleTypeChange(e) { setItemType(e.target.value); setTypeFields({}); // clear leftover fields from whatever type was selected before }
The Same Line, a Different Reason to Need It
The MongoDB sibling's own version of this fix was framed honestly as a UX correction, not a correctness one — Mongoose's strict mode was already protecting the actual saved data regardless. Here, that framing doesn't hold: without this reset, a real, permanently stored field ends up on the wrong kind of document, because nothing in this app's own stack — not validateItem(), not the Security Rules — was ever built to reject fields it doesn't recognize. This one line is doing real correctness work, not just tidying up a confusing input.

Closing this specific gap more thoroughly — rejecting a write outright if it carries a field no valid item type actually uses — is a real option worth naming rather than building here: Firestore Security Rules support exactly that check, via request.resource.data.keys().hasOnly([...]). Chapter 3's own rules don't do this today; the one-line client-side reset below is what actually keeps this app's real data clean until that stronger rule, if it's ever added, exists as a second line of defense.

Arrays From Text, and Why Numbers Need Real Conversion Here

Comma-separated text becomes a real array immediately before the write, exactly as the MongoDB sibling's own form does it — split, trimmed, and filtered, never stored as an array in state while it's still being typed:

// AddItemForm.jsx (continued) — inside handleSubmit, before the write 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); } }

releaseYear and runtimeMinutes need the same real-vs-string treatment the MongoDB sibling's own form gives them — but the reason to bother is genuinely stronger here. Every DOM input produces a string regardless of its declared type, and Mongoose's own Number SchemaType casts a valid numeric string to a real number automatically on write, whether the client remembers to convert it or not. Firestore does no such thing. Whatever JavaScript type a value has when it's handed to addDoc() is exactly the type Firestore stores — a string stays a string, permanently:

await addDoc(collection(db, "items"), { itemType: "Dvd", title: "Spirited Away", runtimeMinutes: "125", // a string, not converted }); // stored exactly as sent — runtimeMinutes is a real Firestore string field, not a number
Why a Stored String Instead of a Number Actually Matters
Firestore's own inequality queries (where("runtimeMinutes", ">", 120), the kind a future "movies over two hours" filter would need) compare values of matching type — a document whose runtimeMinutes is stored as the string "125" is simply invisible to a query filtering on the number 120, with no error raised anywhere to explain why it never shows up. The conversion below isn't optional tidiness here; skipping it produces a real document that quietly breaks a real future query.

Submitting the Form

// AddItemForm.jsx (continued) async function handleSubmit(e) { e.preventDefault(); setError(null); 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.type === 'number' && payload[field.name] !== undefined) { payload[field.name] = Number(payload[field.name]); } if (field.isArray && typeof payload[field.name] === 'string') { payload[field.name] = payload[field.name].split(',').map((s) => s.trim()).filter(Boolean); } } try { validateItem(payload); // throws instantly, no round trip, if data is malformed } catch (err) { setError(err.message); return; } try { const ref = await addDoc(collection(db, "items"), payload); onCreated({ id: ref.id, ...payload }); setShared({ title: '', releaseYear: '', notes: '' }); setTypeFields({}); } catch (err) { setError("Save failed — this data was rejected by the server."); // a real Security Rules rejection } }

Two separate catch blocks are deliberate. The first catches validateItem()'s own synchronous throw — a specific, immediate message, no network request ever sent. The second catches whatever addDoc() itself might reject, which in practice should only ever be a real Security Rules denial, since anything validateItem() would have caught already stopped the function one line earlier.

Trying It in the Browser

With the Firestore project's rules deployed from Chapter 3, mounting <AddItemForm onCreated={(item) => console.log('Created:', item)} /> is enough to test all four types end to end: select each type, fill in its own fields, and confirm the logged item — then cross-check directly in the Firebase console's own Firestore data viewer that all four documents exist in the items collection, each with genuinely different fields, and none of them carrying a stray field left over from whichever type was selected before it. Chapter 7 replaces this 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 the app, and create one real item of each type through the actual UI, confirming each one via the Firebase console's Firestore data viewer.

📄 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 directly that the saved Cd document really does carry a stale author field this time — unlike the MongoDB sibling's own version of the same bug. Explain why the outcome differs.

📄 View solution
Exercise 3

Explain why omitting the explicit Number() conversion on runtimeMinutes would be a genuinely more serious mistake in this course than in the MongoDB sibling, and describe a concrete, later scenario where the resulting bug would surface.

📄 View solution

Chapter 4 Quick Reference

  • FIELD_CONFIG — the identical declarative object the MongoDB sibling introduced, reused verbatim against this course's own Chapter 2 fields
  • Shared vs. type-specific state — title/releaseYear/notes live in one piece of state; type-specific fields live in another, reset whenever itemType changes
  • The leftover-field bug, this time uncaught — Firestore has no schema to silently drop an unexpected field the way Mongoose's strict mode does; the one-line reset is a real correctness fix here, not just a UX one
  • Arrays from text — comma-separated input split/trimmed/filtered into a real array immediately before the write
  • Numbers need real conversion — Firestore stores exactly the JS type it's handed, with no automatic casting; skipping Number() leaves a string field a future numeric query would silently never match
  • Two error paths — validateItem()'s synchronous throw (fast, specific) vs. a Security Rules rejection surfaced through addDoc() (a real network round trip)
  • Next chapter: Tags — storing, attaching, and filtering by tag on books specifically