Back to path
IntermediatePulse · Project 4 of 12 ~6h· 5 milestones

Add forms that validate and stay accessible

Continues from the last build: You left Pulse as a read-only console, metrics and the event stream load real data through TanStack Query, complete with loading, empty, and error states.

Right now Pulse can only show the product team what already exists.

Controlled forms in ReactClient-side validation designAccessible error messaging with aria-describedbyFocus management on errorTanStack Query mutationsMapping server 422 responses to fieldsDiscriminated-union submit stateLive regions for async status

What you'll build

A validated, accessible "create segment" form wired to POST /api/segments, with inline errors linked to inputs via aria-describedby, focus moved to the first invalid field on both client and server rejection, and a submit button whose state (idle, submitting, success, error) is driven by one discriminated union instead of scattered booleans.

See how we teach, before you sign up

You don't just get code dumped on you. Every starter file and every solution is explained line-by-line, in plain English. Here's one real file from this project:

src/features/segments/SegmentForm.tsxtsx
import { useState } from 'react';

interface SegmentFormState {
  name: string;
  rule: string;
}

const initialState: SegmentFormState = { name: '', rule: '' };

export function SegmentForm() {
  const [values, setValues] = useState<SegmentFormState>(initialState);

  function handleChange(field: keyof SegmentFormState) {
    return (event: React.ChangeEvent<HTMLInputElement>) => {
      setValues((prev) => ({ ...prev, [field]: event.target.value }));
    };
  }

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    console.log('submitting', values);
    // TODO: no validation, no server call, no error state yet
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="segment-name">Segment name</label>
      <input id="segment-name" value={values.name} onChange={handleChange('name')} />

      <label htmlFor="segment-rule">Rule</label>
      <input id="segment-rule" value={values.rule} onChange={handleChange('rule')} />

      <button type="submit">Create segment</button>
    </form>
  );
}

Reading this file

  • const [values, setValues] = useState<SegmentFormState>(initialState);One controlled state object holding both fields, not two separate useState calls.
  • function handleChange(field: keyof SegmentFormState) {A single generic handler keyed by field name instead of one handler per input.
  • // TODO: no validation, no server call, no error state yetExactly what this rung's milestones fill in, in order.
  • <button type="submit">Create segment</button>Will need pending and disabled states once the mutation exists.

Your starting point. It renders two fields and a button, and logs on submit, but has no validation, no request, and no error state.

That's 1 of 8 explained code blocks in this single project.

The build, milestone by milestone

  1. 1

    Model validation as data, not scattered ifs

    5 guided steps

    If validation logic is buried inside the submit handler as ad-hoc if statements, you cannot reuse it, test it, or reason about what "invalid" means. A pure function that takes form values and returns FieldError[] can be unit tested in isolation and reused for both client and server error rendering.

  2. 2

    Wire errors to inputs accessibly

    5 guided steps

    aria-describedby is what lets assistive technology announce "Segment name, invalid entry, Segment name must be at least 3 characters" instead of just the label. Focus management matters because after a failed submit, a keyboard user's focus is still sitting on the submit button; without moving it, they have no idea where the problem is.

  3. 3

    Submit through a TanStack Query mutation

    5 guided steps

    useMutation gives you loading state, error state, and cache invalidation for free, and is the same tool you already used for reads in rung 3. Modeling submit state as one union prevents impossible combinations like isSubmitting: true and isSuccess: true existing at once.

  4. 4

    Map server 422 errors back onto the same fields

    5 guided steps

    A server can catch things the client cannot, like a segment name that already exists. If the 422 response is ignored or just shown as a generic toast, the user has no idea which field to fix, and has to guess-and-resubmit. Reusing the client validation's error UI means one code path handles both sources of truth.

  5. 5

    Polish: reset, success feedback, and honest states

    4 guided steps

    A form that clears itself but leaves focus stranded on a disabled button, or a button that says "Create segment" while a request is actually in flight, both erode trust. Consistent state-to-UI mapping is what makes a form feel reliable under a flaky network, not just under the happy path.

What's inside when you start

3 starter files, ready to clone
5 guided milestones
5 full reference solutions
8 code blocks explained line-by-line
5 "is it working?" checks
4 interview questions it prepares you for

You'll walk away with

A create-segment form with client-side validation that runs before any network request
Every input wired to its error via aria-describedby, aria-invalid, and a matching error span id
Focus moved to the first invalid field on both client-validation failure and server 422 rejection
A TanStack Query mutation driving one SubmitState union (idle, submitting, success, error) instead of separate booleans
A mapServerErrors function translating the API's 422 body into the same FieldError shape as client validation

This is portfolio-grade. Build it free.

Sign up to unlock every milestone step-by-step, the code skeletons, full reference solutions, and checkable tasks, with your progress saved as you build.

Start building