Without notes, state yesterday’s main idea and one unresolved question.
Create and edit workflows
Connecting the Full Stack
Objective
Coordinate forms, API mutations, validation, and refreshed state.
This is system integration: individual modules may pass bench tests but the complete signal path must also be verified end to end.
- POST and PATCH flows
- server validation errors
- optimistic versus confirmed updates
Why this matters
Yesterday you read data. Reading is the easy half: if a GET fails you show an error and nothing is
lost. Today you write — and a write that half-succeeds leaves the database in one state, the
screen in another, and the user with no idea which to believe. By the end you will have add and edit
forms that show the server's own field errors, refresh the list correctly, and — the real test —
still tell the truth after you reload the page.
POST and PATCH: two different promises
On Day 36 you met HTTP methods. Now you use the three that change things, and the differences matter because they decide what your UI is allowed to assume.
| Method | Means | Success status |
|---|---|---|
POST /api/equipment |
create a new row; the server assigns the id | 201 Created |
PATCH /api/equipment/7 |
change some fields of row 7 | 200 OK |
PUT /api/equipment/7 |
replace row 7 entirely with what I sent | 200 OK |
PATCH sends only the fields you changed. PUT sends everything, and any field you omit is
supposed to be cleared. Sending a half-filled PUT and wiping columns you never meant to touch is a
classic first-week-of-integration bug. This course uses PATCH for edits.
The other difference is idempotency: sending the same PATCH or PUT twice leaves the same
result, but sending the same POST twice creates two rows. That is why an impatient
double-click on Save is a data problem, not a cosmetic one.
Write, then read back
When you program an EEPROM you do not assume the write landed — you read the cell back and compare.
The write can fail on a marginal supply, the bus can glitch, and the part will not tell you. A
POST is the write; the response body or a follow-up GET is the read-back. Today's deliverable
is exactly the read-back: reload the page and confirm the value that comes off the disk matches
what the screen claimed.
Both POST and PATCH carry a JSON body and therefore Content-Type: application/json — which, as
you learned yesterday, means the browser preflights them. Two Network rows per save is correct.
Server validation errors
Your form checks fields before submitting. That is worth doing: it is faster than a round trip and
kinder to the user. But it is not validation in any meaningful sense, because the browser is
under the user's control (Day 1) and curl skips your form entirely.
Server validation is the authoritative result; client validation only improves the experience. The server checks every request, every time, regardless of what the form did. If the two disagree, the server wins — and if you ever have to delete one of them, delete the client one.
For that to be usable, the server has to say which field was wrong in a shape the frontend can read. Agree on one error contract and use it everywhere — this is the Day 40 error handling you built, now with a consumer:
{
"error": "ValidationError",
"message": "Request body failed validation",
"fields": {
"name": "Name is required",
"status": "Must be one of: active, maintenance, retired"
}
}
Three status codes cover almost everything you will send back:
400 Bad Request— the body is wrong. Return thefieldsobject.409 Conflict— the body is fine but collides with existing data, for example a duplicate asset tag. YourUNIQUEconstraint from Day 44 raises PostgreSQL error code23505; catch it and translate, rather than letting a 500 escape.404 Not Found— you are patching an id that does not exist.
Never let a database error reach the user
A raw duplicate key value violates unique constraint "equipment_asset_tag_key" in the UI leaks
your schema and means nothing to a technician. Catch it at the service boundary, log the original
(Day 54), and return your own contract.
Optimistic versus confirmed updates
When the user hits Save, you have two honest options.
A confirmed update waits for the response, then applies it: show a pending state, and only put
the new row on screen once the server has said 201 and handed back the saved object. Slower by one
round trip, and always truthful.
An optimistic update changes the screen immediately, assuming success, and rolls the change back if the request fails. It feels instant. It also means you must keep the old value to restore, decide what to show during rollback, and accept that the screen was briefly lying.
The receipt
A confirmed update is waiting for the card machine to print the receipt before you walk out. An optimistic update is walking out when the terminal beeps. The beep is usually right — but when it is not, you have to come back, and someone has to explain the difference.
Use confirmed updates for everything this week. Optimistic updates are a real technique, but they are an optimisation, and optimising a data path you have not yet made correct is how integration weeks get lost. Once the response arrives you have two ways to refresh the list: use the returned object (fast, one request, but only correct if the response contains the full saved row) or re-fetch the list (one extra request, guaranteed to match the database, and it also picks up anything the server computed for you). Prefer re-fetching until it hurts.
The test that settles all of this is the one in today's deliverable: press F5. A reload throws away every piece of frontend state and rebuilds the screen from the database. If the row you just created is still there, with the values you typed, the write really happened.
Walkthrough
First, teach the API helper to carry field errors. Yesterday's apiFetch threw away the response
body on failure; that body is exactly what you now need.
export class ApiError extends Error {
constructor(
public status: number,
public fields: Record<string, string> = {}
) {
super(`Request failed with ${status}`);
}
}
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${BASE_URL}${path}`, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(response.status, body.fields ?? {});
}
return response.json() as Promise<T>;
}
The .catch(() => ({})) matters: an error response is not guaranteed to contain JSON. A crashed
proxy may return HTML, and response.json() would then throw a parse error that hides the real
status code.
On the server, the route validates, then translates known failures:
router.patch('/:id', async (req, res, next) => {
const fields = validateEquipmentPatch(req.body);
if (Object.keys(fields).length > 0) {
return res.status(400).json({ error: 'ValidationError', fields });
}
try {
const updated = await equipmentService.update(Number(req.params.id), req.body);
if (!updated) return res.status(404).json({ error: 'NotFound' });
return res.json(updated);
} catch (err) {
if (err.code === '23505') {
return res.status(409).json({
error: 'Conflict',
fields: { assetTag: 'That asset tag is already in use' }
});
}
return next(err);
}
});
Now the form. Note what is not here: no clearing of inputs before success, and no way to submit twice.
const [pending, setPending] = useState(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (pending) return;
setPending(true);
setFieldErrors({});
setFormError(null);
try {
await apiFetch<Equipment>('/api/equipment', {
method: 'POST',
body: JSON.stringify({ name, status })
});
await reloadEquipment(); // confirmed update: refresh from the server
setName(''); // clear only after success
} catch (err) {
if (err instanceof ApiError && Object.keys(err.fields).length > 0) {
setFieldErrors(err.fields);
} else {
setFormError('Could not save. Your entries are still here — try again.');
}
} finally {
setPending(false);
}
}
Wire the errors into the markup so screen readers get them too, using the labelling from Day 60:
<input
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={Boolean(fieldErrors.name)}
aria-describedby={fieldErrors.name ? 'name-error' : undefined}
/>
{fieldErrors.name && <p id="name-error">{fieldErrors.name}</p>}
<button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>
Prove the server is the one validating
Comment out your client-side check, submit an empty name, and watch the 400 come back with your
field message rendered next to the input. Now put the check back and send the same empty body with
curl -X POST http://localhost:3000/api/equipment -H 'Content-Type: application/json' -d '{}'.
The same 400 appears. The form was never what was protecting your data.
Reviewer mode — after your forms work
"Review mutation handling for duplicate submissions, stale data, weak errors, and lost form input." Ask for specific findings with evidence — file, line, and the input that triggers the problem — not praise and not a rewrite. Anything it cannot point at, treat as noise.
Your turn
- Add
ApiErrortosrc/lib/api.tsand makeapiFetchparse the error body. - Add server-side validation to your
POST /api/equipmentandPATCH /api/equipment/:idroutes, returning thefieldscontract with400. - Translate a duplicate-key failure into
409with a field message. Test it by adding the same asset tag twice. - Connect the Add form: pending state, disabled button, field errors rendered next to inputs, list refreshed on success, inputs cleared only after success.
- Connect the Edit form the same way with
PATCH, sending only changed fields. - Double-click Save deliberately and check the database:
SELECT count(*) FROM equipment WHERE name = 'Test pump';It must return1. - Force a failure by stopping the API mid-edit. Confirm your typed values are still in the inputs.
- Reload the page. Confirm the created and edited rows are exactly as you left them.
You are done when
Create and edit both survive a page refresh, an invalid submission shows the server's message beside the right field, and a failed submission loses nothing the user typed.
Common pitfalls
- Clearing the form before the response arrives. The request fails, the inputs are empty, and the user retypes everything. Clear on success only.
- Trusting client validation. If the button is enabled only when the form is valid, the server
must still return
400for an invalid body. Test it withcurl, not with the form. - Swallowing the error body. Calling
res.json()only on success throws away the one thing that tells the user which field is wrong. - Forgetting to refresh the list. The row is in the database and not on screen, so the user saves again — and now there are two.
Verify it yourself
Open today's reference, MDN's Fetching data from the server, and find where it covers sending data rather than reading it.
- This lesson set
Content-Type: application/jsonby hand and calledJSON.stringifyon the body. Find MDN's treatment of request bodies and note one body type it supports that is not JSON. - Does MDN describe a way to cancel an in-flight request? Name it, and say what it would do to the
pendingflag in the form above.
Record both answers next to your Day 64 trace. Reading how the platform actually behaves, rather than inferring it from one working example, is what stops you cargo-culting the next integration.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Connect add and edit forms. Display server field errors and update the list after success.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
Create/edit workflows that remain correct after page refresh.
Working with AI today
Provide existing work and ask for concrete defects, risks, missing tests, and unsupported assumptions—not praise.
Review mutation handling for duplicate submissions, stale data, weak errors, and lost form input.
References
End-of-day quiz
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.