DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Four form components hold every form in our app, and writing this post found the one bug in them

Nakodo has no form library. No React Hook Form, no Formik, no resolver. Forms are Server Actions with useActionState, inputs are plain inputs, and everything the forms share lives in src/components/forms.tsx, which is 162 lines and exports four components and two class name strings. Fifteen files import from it.

That is the whole abstraction, and it is worth writing down because the interesting decisions in a form system are not validation. They are what happens after submit, what a hint is, and where the Save button goes.

FormResult: 19 banners, two ARIA roles, one of them deliberate

Every form in the app ends with the same component, 19 times across 11 files:

export function FormResult({ state, card = false, className }: { state: Result; card?: boolean; className?: string }) {
  if (state?.error) {
    return (
      <p role="alert" className={cn("text-sm text-danger", className)}>
        {state.error}
        {state.upgrade && (
          <> <Link href={planHref("limit_form")}>See plans</Link></>
        )}
      </p>
    );
  }
  if (!state?.message) return null;
  return card ? (
    <p role="status" className={cn("flex gap-2 rounded-md border bg-card p-4 text-sm", className)}>
      <CheckIcon className="mt-0.5 size-4 shrink-0 text-cyano-700 dark:text-cyano-300" aria-hidden="true" />
      {state.message}
    </p>
  ) : (
    <p role="status" className={cn("text-sm text-muted-foreground", className)}>{state.message}</p>
  );
}
Enter fullscreen mode Exit fullscreen mode

role="alert" for the failure and role="status" for the success is not symmetry for its own sake. alert maps to an assertive live region: a screen reader interrupts whatever it was reading, which is right for "we did not save your work". status is polite: it waits for a pause, which is right for "saved". Getting those the other way round produces a form that shouts about success and whispers about failure.

The upgrade boolean is the other load-bearing part. A plan limit is not a validation error and not a server error; it is a product state with an answer. One LimitError thrown anywhere in an action becomes { ok: false, error: e.message, upgrade: true } at the boundary, and the banner that renders it grows a link to the plans. No form has to know that a limit exists, and no second component renders limit messages differently from other errors.

card exists for the two places where the confirmation is the entire outcome of the page rather than feedback beside fields: the sign-in and sign-up forms, where the result is "we sent a confirmation link to that address" and there is nothing else left on screen to look at.

Field: 43 call sites, and the bug

export function Field({ id, label, optional, hint, className, children }) {
  return (
    <div className={cn("space-y-1.5", className)}>
      <Label htmlFor={id}>
        {label} {optional && <span className="font-normal text-muted-foreground">(optional)</span>}
      </Label>
      {children}
      {hint && <p className="text-[13px] text-muted-foreground">{hint}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

43 uses across 9 files, 18 of them passing a hint. The label is wired to the control through htmlFor, which is the part people remember. The hint is not wired to anything, which is the part people forget, and I am one of them: a <p> after an input is read by a screen reader only if the user happens to keep walking the document, and the field's accessible description stays empty. Someone tabbing through the form hears "Views, edit text" and never hears "Use the median views of the creator's recent videos".

I noticed because the sign-up form does not use this component. Its fields are hand-rolled to fit the password hint onto the label row, and in doing so they did it properly. You can check both live, without an account:

  • The sign-up form renders <span id="password-hint" class="text-[13px] ...">At least 8 characters</span> and the input beside it carries aria-describedby="password-hint".
  • The sponsorship cost calculator renders seven text-[13px] hints through Field, and the page contains zero occurrences of aria-describedby.

The fix is to default the id (${id}-hint), put it on the paragraph, and pass it down to the control, which the component cannot do today because it renders children rather than owning the input. That last part is the actual design flaw: a field wrapper that takes an arbitrary child cannot add attributes to it, so the wiring has to be the caller's job, which means it will be forgotten. Either Field takes the input's props and renders it, or the hint id has to be returned to the caller. I have left it as it is for now and written it down rather than quietly shipping a redesign in the middle of a post, but it is a real gap in an app whose public pages are otherwise checked for this sort of thing.

ChoiceCards: the selected state never reaches React

Four uses, and not one of them tracks which card looks selected:

export const choiceCardClass =
  "cursor-pointer rounded-lg border bg-card transition-colors hover:border-ink-400 has-checked:border-primary has-checked:bg-selected has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-ring";
Enter fullscreen mode Exit fullscreen mode

The radio stays a real radio inside a real <label>, so arrow key navigation, the form's own value, required validation and screen reader semantics all come free. Tailwind 4's has-checked: and has-focus-visible: variants compile to :has(:checked) and :has(:focus-visible) on the card, so the card paints itself from the input's state. In the grid layout the radio is sr-only and the whole card is the control; in the list layout it stays visible with accent-primary, because a narrow panel reads better with the dot showing.

The one React subtlety is supporting controlled and uncontrolled use from one component:

checked={value === undefined ? undefined : value === o.value}
defaultChecked={defaultValue === undefined ? undefined : defaultValue === o.value}
Enter fullscreen mode Exit fullscreen mode

Passing checked={false} instead of undefined would make it a controlled input with no onChange in the uncontrolled case, which React warns about and which freezes the radio. Passing undefined leaves the attribute off entirely, so the same JSX is either controlled by a parent's state or left to the form's own FormData. Two of the four call sites are controlled by a parent's state (the brief and proposal editors, which compare the choice against what is saved) and two are left to the form.

PanelForm: Save stays in view

<form action={action} className="flex min-h-0 flex-1 flex-col">
  {top}
  <div className={cn("min-h-0 flex-1 overflow-y-auto px-4", bodyClassName)}>{children}</div>
  <div className="flex items-center justify-end gap-2 border-t px-4 py-3">...</div>
</form>
Enter fullscreen mode Exit fullscreen mode

Editors in the app open in a side sheet, and a long brief scrolls. The footer is not sticky, it is a flex sibling that is not allowed to shrink, which means no position: sticky, no z-index, and no gap under the last field. The two min-h-0 are the trick: a flex child defaults to min-height: auto, so the scrolling body would otherwise refuse to be smaller than its content and push the footer off the bottom of the sheet. The exported panelContentClass sets the sheet's width in six places, including the data-[side=right]: variants Radix needs, so one string decides how wide every editor panel is.

While counting call sites for this post I also found that choiceCardClass is exported and used only inside its own module. eslint-config-next does not flag unused exports, so a public-looking name with no consumers can sit there indefinitely. It is two lines of cleanup, but it is also a good argument for counting your own call sites occasionally.

Why not a form library

Because none of the four components above is about forms. They are about this product's answers to four questions: what does a failed save look like, what is a hint, what does a choice look like, and where does Save live. A library would have given me validation I do not need (the actions validate with Zod, server side, where it counts) and would not have given me a single opinion about any of those four. 162 lines, four components, one real bug found by writing them up in public.

Top comments (0)