0 / 91
Week 10 · Day 65 of 91

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 the fields object.
  • 409 Conflict — the body is fine but collides with existing data, for example a duplicate asset tag. Your UNIQUE constraint from Day 44 raises PostgreSQL error code 23505; 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

  1. Add ApiError to src/lib/api.ts and make apiFetch parse the error body.
  2. Add server-side validation to your POST /api/equipment and PATCH /api/equipment/:id routes, returning the fields contract with 400.
  3. Translate a duplicate-key failure into 409 with a field message. Test it by adding the same asset tag twice.
  4. Connect the Add form: pending state, disabled button, field errors rendered next to inputs, list refreshed on success, inputs cleared only after success.
  5. Connect the Edit form the same way with PATCH, sending only changed fields.
  6. Double-click Save deliberately and check the database: SELECT count(*) FROM equipment WHERE name = 'Test pump'; It must return 1.
  7. Force a failure by stopping the API mid-edit. Confirm your typed values are still in the inputs.
  8. 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 400 for an invalid body. Test it with curl, 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.

  1. This lesson set Content-Type: application/json by hand and called JSON.stringify on the body. Find MDN's treatment of request bodies and note one body type it supports that is not JSON.
  2. Does MDN describe a way to cancel an in-flight request? Name it, and say what it would do to the pending flag 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

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Connect add and edit forms. Display server field errors and update the list after success.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

Create/edit workflows that remain correct after page refresh.

Working with AI today

AI as skeptical reviewer

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

Q1 Which validation result is authoritative?
Q2 Which result best proves today’s work is complete?
Q3 What should an AI code review primarily produce?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.