Old-School Front-End · Frameworks / 2026 ・ No.06

Old-School Front-End: Frameworks

Part 6: Forms and input checks — controlled and uncontrolled, Actions, and the double click

Published

A browser showing the React claim form just after Submit was pressed. A red list at the top, Please check your entries, has three items: Enter a purpose; Enter the amount for row 2 in digits only, like 14720; Read the expenses policy and tick the box. The purpose box, row 2's amount 12,800 yen and the policy tick are red
Stopped on screen before anything went to the server, in the same words as the front-end series (Taken by the author on a cloud Linux machine on 2 November 2026. The bar above each window was drawn in for explanation.)

Since part 5 the rows work, but nothing can be sent. Part 6 adds a submit using React 19 Actions (useActionState and useFormStatus) and checks the claim with the same rules as the front-end series' parts 9 and 10, both on screen and on the server.
Then we cause, and measure, three stumbles on purpose: the purpose vanishing after a send, a double click making two claims, and an error sliding onto the next row.

1

Where the values live

AraiTo send it, I'll put the purpose and notes in useState too, and fetch from the button. Done.

FuruiBefore making everything state, look at where the values are now. In part 5 only the line items went into state.

The 15-part route map (you are here: part 6)

1 Why FWs 2 jQuery→MVC 3 After React 4 First React 5 State 6 Forms 7 Fetching 8 Render cost 9 Where state 10 Routing 11 Optimistic 12 Server Comp. 13 Caching 14 Testing 15 Ship & choose You are here Why & history React basics Speed & state Pages & server Next.js Finishing

The claim form's boxes come in two kinds. The line items are tied to state with value and onChange: controlled. The purpose, payment method, notes, policy tick and receipt file leave their values to the browser: uncontrolled. As part 5's section 5 measured, typing a purpose re-renders nothing.

Where the values live

kept in state (controlled) value={row.amount} onChange line items: date, amount… re-renders on every key totals, counts, errors read it left to the browser (uncontrolled) name="title" (no value) purpose, payment, notes, tick receipt file (can't be set) typing re-renders nothing FormData on send, gathers both by name State only what other parts read; the rest can stay in the browser But <form action> empties the right-hand side after sending (section 6)

For sending, either kind is fine. The browser's FormData gathers both, using each name. State only needs the values other components use (totals, counts, error rows). A file box can't be given a value by code at all, so it has to be uncontrolled.

2

Sending with Actions

/** Turn 'amount-2' (row 2's amount) into the id of row 2 at send time, so errors don't slide to a neighbour when a row is removed */
function tie(errors, ids) {
  return errors.map((e) => {
    const m = /^(date|category|item|amount)-(\d+)$/.exec(e.field);
    return { ...e, key: m ? `${m[1]}:${ids[Number(m[2]) - 1] ?? ids[0]}` : e.field };
  });
}

/** The work done on send (the Action): takes the previous result and the form data, returns the next result */
async function submitClaim(prev, formData) {
  const ids = formData.getAll('rowId').map(Number);
  const draft = formData.get('action') === 'draft'; // drafts are stored without checking
  const at = prev.at + 1;
  if (!draft && CHECK) {
    const errors = validateClaim(claimOf(formData), localDate(), RATE);
    if (errors.length > 0) return { errors: tie(errors, ids), at, from: 'client' };
  }
  const r = await sendClaim(formData);
  return { ...r, errors: tie(r.errors ?? [], ids), at, from: 'server' };
}

/** The claim form screen. The rows state lives here, in one place only */
export default function App() {
  countRender('App');
  const [rows, setRows] = useState(SAMPLE_ROWS);
  const [result, formAction] = useActionState(submitClaim, { errors: [], at: 0 });

  // … (change, add and remove as in part 5)

  // With <form action={…}>, React empties the boxes after sending (even on errors).
  // Here onSubmit takes it, so neither the browser nor React empties anything, and we call formAction ourselves
  const onSubmit = (event) => {
    event.preventDefault();
    const formData = new FormData(event.currentTarget, event.nativeEvent.submitter); // include the pressed button (submit / draft)
    startTransition(() => formAction(formData));
  };

  return ( … <form onSubmit={onSubmit} noValidate> … </form> … );
}

React 19 wraps the work done on send in a function called an Action. useActionState(submitClaim, initial) returns the current result and formAction, which calls the Action. submitClaim just takes the previous result and the form data, and returns the next result. When the result changes, the screen re-renders.

The flow of a submit

press Submit / Save draft onSubmit build FormData startTransition formAction(data) submitClaim the Action screen check validateClaim return { errors } nothing sent fetch /api/expenses practice server same rules, again next result 400 errors / 201 id render list, red boxes, id while sending buttons disabled fails passes Screen checks are for speed, server checks for safety: same rules An Action takes the last result and the data, returns the next

Normally <form action={formAction}> is all it takes. This app deliberately takes the event in onSubmit and calls formAction itself, inside startTransition. Section 6 measures why.

3

Check on screen first

/** Build the claim from FormData (read the same way as the practice server) */
export function claimOf(formData) {
  const all = (k) => formData.getAll(k).map(String);
  const [d, c, i, a, cur] = ['date', 'category', 'item', 'amount', 'currency'].map(all);
  return {
    title: String(formData.get('title') ?? ''),
    rows: d.map((_, n) => ({ date: d[n], category: c[n], item: i[n], amount: a[n], currency: cur[n] })),
    receipts: formData.getAll('receipt').filter((f) => f.size > 0).length,
    agreed: formData.get('agree') === 'yes',
  };
}

/** Check the claim and return [{ field: 'amount-2', message: '…' }, …] (an empty array means it passes) */
export function validateClaim(claim, today = localDate(), rate = null) {
  const errors = [];
  // … (the same rules as the front-end series, parts 9 and 10)
}

The rules are the front-end series' validate.js from part 9 and part 10, turned into something you can import. The practice server re-checks with the same validate.js, so even the wording matches. FormData is read the same way as on the server.

# The same mistakes (no purpose, a currency word in row 2's amount, no tick), then Submit
                        screen check          server check (?check=off)
  requests              0                     1
  until the list shows  21.9 ms               627 ms
  items in the list     3                     2 (purpose was filled)
  focus                 #error-summary        #error-summary
# Medians of five runs on the production build; 600 ms of the server's time is the practice server's deliberate wait
# The wording is identical on both sides (same rules, same sentences). Below: the server's list
  Enter the amount for row 2 in digits only, like 14720.
  Read the expenses policy and tick the box.

The screen check showed its list in about 0.02 seconds without sending anything. The server check took about 0.63 seconds (mostly the practice server's deliberate wait). The words are the same. The screen checks for speed and kindness, the server for safety. As section 5 shows, anyone can skip the screen.

# JavaScript from npm run build (part 5 → part 6)
  index-*.js   229.71 kB → 239.35 kB   gzip 72.57 kB → 75.95 kB
# The extra 3.4 kB (gzip) is the rules and the messages in three languages; Actions are already part of React
4

Showing the mistakes

/** The list of input mistakes. When it appears, focus moves here (so screen readers read the list first) */
export default function ErrorSummary({ errors }) {
  const go = (event, key) => { // clicking an item jumps to its box
    event.preventDefault();
    document.getElementById(key.replace(':', '-r'))?.focus();
  };
  return (
    <div className="alert alert-danger" id="error-summary" tabIndex={-1} ref={(el) => el?.focus()}>
      <h2 className="h6 alert-heading">{T.check}</h2>
      <ul className="mb-0">
        {errors.map((e) => (
          <li key={e.key + e.message} id={`err-${e.key.replace(':', '-r')}`}>
            <a href={`#${e.key.replace(':', '-r')}`} className="alert-link" onClick={(event) => go(event, e.key)}>{e.message}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

The display follows front-end part 9: a list at the top that takes focus, and each box gets aria-invalid plus aria-describedby pointing at its list item. Moving focus needs no useEffect. Pass a function as the list's ref and it's called when the list appears. Each send changes the list's key so it is built afresh, and focus returns on the second "no" too.

The awkward part: the server answers with amount-2 (row 2's amount), a position. The same trap as part 5's key is waiting here.

# The server said "amount-2 (row 2's amount) is wrong"; then row 1 was removed
  errors remembered by            red before            red after
  position (2)                    Hotel, 1 night        Lunch with a client (moved)
  row id (row 2 at send time)     Hotel, 1 night        Hotel, 1 night
# Same in the claim form: remembered by id, the red stayed on the hotel's amount after row 1 went

Remembered by position, the red box jumped to Lunch the moment row 1 was removed. So each row sends an <input type="hidden" name="rowId">, and the Action swaps the position for the id of "row 2 at send time" (tie in section 2). It's fixed on the screen side, without touching the server.

5

Forwarding to the practice server

The destination is the front-end series' practice server (port 3000). React's dev server is on 5173, so as things stand that's a different place (origin). We ask Vite to forward it.

export default defineConfig({
  plugins: [react()],
  server: {
    // Part 6: forward calls starting with /api to the practice server (same origin, as the browser sees it)
    proxy: { '/api': process.env.KEIHI_API ?? 'http://localhost:3000' },
  },
})
import { T } from './text.js';

export async function sendClaim(formData) {
  const res = await fetch('/api/expenses', { method: 'POST', body: formData });
  const body = await res.json().catch(() => ({}));
  if (res.status === 201) return { id: body.id, draft: Boolean(body.draft) };
  if (res.status === 400 && Array.isArray(body.errors)) return { errors: body.errors };
  if (res.status === 401) return { message: T.signedOut }; // sign-in troubles are part 7
  return { message: T.failed(res.status) };
}

As the browser sees it, the destination is /api/expenses on the same 5173. Neither CORS nor CSRF shows up yet (that's part 7, with the real Spring Boot). Sign in first on the practice server's own page (localhost:3000). Cookies don't care about ports, so the cookie from 3000 is sent to 5173 as well.

A terminal. curl without signing in gets 401. Signing in on the practice server (port 3002) gets 200. With that cookie, a claim with no purpose and amount 14720円 sent to port 5175 returns title Enter a purpose; amount-1 Enter the amount for row 1 in digits only, like 14720; agree Read the expenses policy and tick the box
Skip the screen and send directly, and the server stops it in the same words (Taken by the author on a cloud Linux machine on 2 November 2026. The bar above each window was drawn in for explanation.)

Skipping the screen, we sent with curl. Without signing in: 401. With the signed-in cookie, the server named the same three problems (no purpose, "14720円", no tick) in the same words as the screen. The screen's check can be skipped; the server's can't. That's why both exist.

6

Sent, and the purpose was gone

AraiWith <form action>, the purpose box was empty after the "no". Do I type it all again...?

FuruiAnd pick the receipt again. Let's compare three ways of sending.

Three small browser windows, each after filling purpose Osaka trip, amount 15000, receipt receipt.pdf and the tick, sending, and getting a no. ① form action: purpose empty, no file chosen, tick cleared. ② with defaultValue: only the purpose came back. ③ sending from onSubmit: purpose, file and tick all kept. The amount is 15000 in all three
Same input, different ways of sending. The amount, tied to state, survives every time (Taken by the author on a cloud Linux machine on 2 November 2026. The bar above each window was drawn in for explanation.)
# Filled purpose, amount, receipt and tick, sent; 600 ms later the Action said no
  how                           purpose     amount    receipt   tick
  ① <form action>emptykept0cleared
  ② ① + defaultValuekeptkept0cleared
  ③ send from onSubmitkeptkept1kept
# The amount is tied to state (value), so it survives every time. The receipt column is the number of chosen files

                          useFormStatus in a child  useFormStatus where <form> is drawn   isPending
  ① <form action>true → falsefalsetrue → false
  ③ send from onSubmittrue → falsefalsetrue → false
# pending lasted 603 ms in both. The pressed button's value: null with new FormData(form) alone, "submit" when the submitter is passed

① With <form action={formAction}>, React empties every uncontrolled box once the Action finishes. That's meant as a kindness after success, but when the Action returns a "no", React still sees it as finished, and empties them. The purpose, the chosen file and the tick all went.

② The usual fix is to put the received values into the result and restore them with defaultValue. The purpose came back, but the file can't (code can't give that box a value). Restoring the tick means adding more, box by box.

③ Call preventDefault() in onSubmit and call formAction inside startTransition, and nobody empties anything. useFormStatus's pending still behaved exactly as in ①, because React treats a send started inside onSubmit as that <form>'s send. This app uses ③. To empty the form after a success, ask for it yourself with requestFormReset.

One catch with ③: new FormData(form) alone leaves out the pressed button (submit or draft), giving null. Pass event.nativeEvent.submitter as the second argument and it's there.

7

Two clicks, two claims

# lab.html?lab=double: a claim that always passes, really sent to the practice server
  how                                       double-click                      two clicks 0.15 s apart
  fetch in onSubmit each time2 0013 00142 0015 0016
  useActionState (button as is)2 0017 00182 0019 0020
  useActionState + disabled while pending1 00211 0022
# The numbers are claim ids (EXP-2026-…). useActionState sent the second only after the first answered (0 ms and 603 ms)
# Double-click in the claim form (SendButtons): 1 request; while sending the button reads "Sending…" and is disabled

Do nothing, and a double click made two claims. We'd rather stop it before an approver signs off the same Shinkansen fare twice. useActionState waits for the first answer before sending the second (it keeps the order), but it doesn't stop the send. Stopping it means making the button unpressable while sending.

/** The Save draft and Submit buttons. They can't be pressed while sending (press twice, still one claim) */
export default function SendButtons() {
  countRender('SendButtons');
  const { pending, data } = useFormStatus(); // is the nearest parent <form> sending right now? data is what it's sending
  const which = pending ? data?.get('action') : null;
  return (
    <div className="d-flex flex-wrap justify-content-end gap-2">
      <button type="submit" className="btn btn-outline-secondary" name="action" value="draft" disabled={pending}>{which === 'draft' ? T.sending : T.draft}</button>
      <button type="submit" className="btn btn-brand" name="action" value="submit" disabled={pending}>{which === 'submit' ? T.sending : T.submit}</button>
    </div>
  );
}

useFormStatus tells you whether the nearest parent <form> is sending. As the middle column of section 6's table shows, called in the very component that draws the <form> (App), it stayed false. So the buttons get a small component of their own. data holds what's being sent, so only the pressed button says "Sending…".

8

Side by side with back-end part 6

# The same "no", written on screen (validate.js and the practice server) and in back-end part 6 (Bean Validation)
  rule                    screen / practice server  back-end part 6
  date empty              date === ''               @NotNull
  date in the future      date > today              @PastOrPresent
  item blank              item.trim() === ''        @NotBlank(strip)
  item too long           (none)                    @Size(max = 100)
  amount empty            toAmount → null           @NotNull
  amount not a number     toAmount → NaN            JSON unreadable, 400
  amount is 0             amount === 0              @Positive
  amount digits           yen whole, $ 2 places     @Digits(9, 0)
  receipt from ¥30,000    needsReceipt              (none)
  reason above ¥50,000    (none)                    @AssertTrue
  policy tick             agreed                    (none)
# The replies look alike too: 400 with a [{ field, message }] list (the back end puts it in ProblemDetail's errors)

In back-end part 6, Spring Boot took a single expense as JSON and checked it with Bean Validation annotations. Most rules have the same shape, but some drift.

  • Rules the screen lacks — the item length (100 characters) and a reason above ¥50,000. They go on the screen too, before part 7 connects the real server
  • Reading differently — the screen fixes a full-width "12800" and lets it through; the back end says 400 at the JSON stage. Sending the screen's cleaned value is the safe choice
  • "Today" — the screen's today is the PC's clock, the server's is the server's (back-end part 6, section 7). Near midnight, something the screen accepts can still be refused by the server

However closely the screen matches, the server decides. Think of the screen as the one who "tells you early, before the server says no", and the drift stops mattering.

9

On your own PC

Start the practice server with node server15.mjs en 3002, sign in at localhost:3002, then run KEIHI_API=http://localhost:3002 npm run dev in the React project and open ?lang=en. Empty the purpose and press Submit: the list appears with nothing at all in DevTools' Network tab. Add ?check=off to the URL and this time /api/expenses comes back 400.

Section 6's experiment is /lab.html?lab=reset&how=action (and values, submit); section 7's is ?lab=double&how=plain. Section 7 really creates claims; restart the practice server and the numbers go back to 0013.

§ — On one page, and next time

  1. Put in state only what other components use. On send, FormData gathers controlled and uncontrolled boxes alike.
  2. An Action turns the previous result and the data into the next result. useActionState returns the result and a way to call it.
  3. The screen check sent nothing and took about 0.02 s; the server took about 0.63 s, in the same words. The screen can be skipped, the server can't.
  4. <form action> empties the boxes even on a "no". Call it from onSubmit inside startTransition and they stay; don't forget the submitter.
  5. Two clicks make two claims. Read useFormStatus in a child component and disable the buttons while sending. Remember errors by row id.

AraiIt sends! But the exchange rate is still hard-coded at 151.23.

FuruiNext time: side effects and fetching data. useEffect fetches the rate, and we connect the real Spring Boot. CORS, CSRF and 401 are waiting together.

References

Recorded by the author on a cloud Linux machine on 2 November 2026. Sending and checks were added to part 5's project (Vite 8.3.5, React 19.3.1), forwarded with Vite's server.proxy to the front-end series' practice servers (Node.js, ports 3000–3002). Both dev (npm run dev) and production (npm run build served by npm run preview) were opened in Chromium 153.0.8010.12 driven by Playwright. Times are medians of five runs on the production build. Sections 6 and 7 used separate pages in lab.html.

Old-School Front-End, Frameworks part 6 — Forms and input checks / 2026.11

← Back to all posts