Field note / react

publishedupdated 2026-05-04#react#forms#validation#optimistic-ui#controlled-inputs

React Forms & Data Flow

The production form patterns I trust: explicit state transitions, server-authoritative validation, pending states, and modern React action flows.

TL;DR

  • Form complexity is rarely about the input element itself. It is about state transitions: editing, validating, submitting, success, failure, retry, and reset.
  • I default to explicit data flow over “magic” because real product forms always grow into async validation, server errors, optimistic updates, and partial failure.
  • Client validation improves speed, but the server remains authoritative for business rules, uniqueness checks, pricing, and access control.
  • Modern React form actions are great when the mutation belongs close to the route, but they still need deliberate error handling and pending-state design.

Context

Simple demo forms hide the problems that matter in production:

  • duplicate submission
  • stale validation state
  • server-side business rule failures
  • optimistic updates that need rollback
  • form reset rules after success or navigation
  • preserving partially entered work when the route refreshes

I design forms around those transitions early, because they are exactly where user trust is won or lost.

The Approach

1. Start by modeling the workflow, not the fields

These are the states I want visible in code:

  • idle
  • editing
  • validating
  • submitting
  • success
  • failure

If a form can meaningfully retry, autosave, or partially save, I model that explicitly instead of hiding it in booleans like isLoading and hasError.

A good form is a state machine in disguise. I want the unhappy path to be as explicit as the happy path.

2. Controlled inputs are still the clearest baseline when correctness matters

For smaller forms, I still like controlled inputs because every transition is obvious:

type SignupState = {
  email: string
  status: "idle" | "submitting" | "success"
  error: string | null
}

const [state, setState] = useState<SignupState>({
  email: "",
  status: "idle",
  error: null,
})

async function onSubmit(event: React.FormEvent) {
  event.preventDefault()

  if (!state.email.includes("@")) {
    setState((current) => ({ ...current, error: "Enter a valid email" }))
    return
  }

  setState((current) => ({ ...current, status: "submitting", error: null }))

  try {
    await createAccount({ email: state.email })
    setState((current) => ({ ...current, status: "success" }))
  } catch (error) {
    setState((current) => ({
      ...current,
      status: "idle",
      error: error instanceof Error ? error.message : "Unable to create account",
    }))
  }
}

This is verbose, but the trade is clarity. On teams with lots of form bugs, clarity wins.

3. Escalate to reducers when transitions matter more than fields

Once the form includes async validation, multi-step navigation, or partial save, I usually stop adding one useState per field and move to a reducer.

type FormState = {
  values: { name: string; email: string }
  fieldErrors: Partial<Record<"name" | "email", string>>
  submitError: string | null
  status: "editing" | "submitting" | "success"
}

type Action =
  | { type: "changed"; field: "name" | "email"; value: string }
  | { type: "fieldErrors"; errors: FormState["fieldErrors"] }
  | { type: "submitStarted" }
  | { type: "submitFailed"; message: string }
  | { type: "submitSucceeded" }

That gives me one place to reason about transition correctness and one place to unit test the workflow.

4. Keep server validation in the loop

Client validation is good for:

  • required fields
  • formatting
  • cheap length checks
  • obvious input constraints

Server validation is required for:

  • uniqueness
  • permissions
  • pricing and inventory
  • business rules
  • tenant-specific policy

That means the form contract needs to support returning field errors and form-level errors separately.

type ActionResult = {
  fieldErrors?: {
    email?: string
  }
  formError?: string
}

async function submitSignup(data: { email: string }): Promise<ActionResult> {
  const response = await fetch("/api/signup", {
    method: "POST",
    body: JSON.stringify(data),
  })

  if (response.status === 409) {
    return {
      fieldErrors: { email: "An account already exists for this email" },
    }
  }

  if (!response.ok) {
    return {
      formError: "Something went wrong. Please try again.",
    }
  }

  return {}
}

5. Modern action-based forms are strong for route-local mutations

In current React and Next.js form flows, useActionState and useFormStatus are useful when the write path belongs close to the route.

'use client'

import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { saveProfile } from './actions'

const initialState = {
  fieldErrors: {} as Record<string, string>,
  message: "",
}

function SubmitButton() {
  const { pending } = useFormStatus()
  return <button disabled={pending}>{pending ? "Saving..." : "Save"}</button>
}

export function ProfileForm() {
  const [state, formAction] = useActionState(saveProfile, initialState)

  return (
    <form action={formAction}>
      <input name="displayName" aria-invalid={Boolean(state.fieldErrors.displayName)} />
      {state.fieldErrors.displayName && <p>{state.fieldErrors.displayName}</p>}
      <SubmitButton />
      {state.message && <p>{state.message}</p>}
    </form>
  )
}

This is a great fit when:

  • the form belongs to one route
  • progressive enhancement matters
  • the server owns validation and mutation
  • I want fewer custom client fetch wrappers

6. Optimistic UI should be rare and deliberate

I only use optimistic updates when rollback is clear. Good candidates:

  • toggles
  • likes
  • lightweight preference changes

Poor candidates:

  • checkout
  • permission changes
  • destructive or financial mutations
  • anything with messy validation and cross-system dependencies

At senior level, the question is not “can I make it feel fast?” It is “can I recover safely when the fast path is wrong?”

Trade-offs

  • Controlled forms are explicit, but they get noisy for very large forms unless I centralize state transitions.
  • Reducers make workflow logic easier to test, but they add ceremony for small forms.
  • Action-based forms reduce client boilerplate, but they can hide the network boundary if the team stops treating the mutation like a real server contract.
  • Uncontrolled approaches can be lighter, but I lose some visibility when live validation and conditional UI get more complex.

References