Verified against Claude Code · 2026-07-25
Convert a form's manual state juggling to React 19 Actions
Migrates an onSubmit-plus-useState form to useActionState, useFormStatus, and useOptimistic only where each genuinely earns its place, instead of hand-rolled pending and error booleans.
The prompt
Ready to copy — highlighted parts are example details you can swap.
You are migrating a React form from manual onSubmit plus useState plumbing to React 19's Actions APIs — the form action prop, useActionState, useFormStatus, and useOptimistic where it genuinely helps and not by default.
CURRENT FORM
A NewsletterSignup form with useState for email, isSubmitting, and error, an onSubmit handler that calls preventDefault, sets isSubmitting, awaits a fetch, and sets error or clears the field on success.
VALIDATION RULES
Email must be non-empty and match a basic email pattern; must not already be subscribed.
SUBMIT BEHAVIOR
On success, clear the field and show a confirmation message inline for five seconds; on failure, show the server error message next to the field without clearing what was typed.
OPTIMISTIC UI NEED
No — this is a low-frequency signup form, waiting a second for a real confirmation is fine.
MIGRATION RULES
Replace the manual isSubmitting, error, and success useState trio with useActionState wrapping a single async action function that performs both the validation and the submission. The action function must return a typed result object — for example { error: string | null } or a field-level error map — and must never throw past the action boundary for expected validation failures; only genuinely unexpected errors (a thrown network exception, a programming bug) should propagate as a thrown error. Use useFormStatus inside a child submit-button component, never inside the same component that renders the form tag itself — useFormStatus only reads pending status from the nearest parent form when it is called from a descendant of that form, and it silently returns default, non-pending values when called anywhere else, which looks like it works in a quick manual test and then never shows a pending state once shipped. Only add useOptimistic if the interface needs to show the result of an action before the server has confirmed it — a new message appearing instantly in a list, a like count incrementing before the request resolves. Do not add it just because it is available; most forms should simply wait for the real result and show a pending state instead. Keep client-side validation for instant per-field feedback while typing, but treat the action's own validation as the actual source of truth — the two must never be allowed to disagree, so if a rule exists client-side, the same rule must exist inside the action function too. If the form is rendered on the server and the action is passed directly to the form element's own action prop rather than triggered from an onClick handler, this preserves basic progressive enhancement — the form can still submit as a real HTTP request even if JavaScript has not finished hydrating yet — so avoid manually calling preventDefault or intercepting the submit event in a way that would defeat that; let the form's action prop own the submission. After a successful action that should clear the form, trigger the reset from the action's own returned state — for example by changing a key on the form or calling a ref's reset method in response to the new state — rather than manually clearing individual field values one at a time, which is easy to leave out of sync with whatever fields the form actually has.
OUTPUT FORMAT
1. The migrated form component and its separate submit-button child component, each in its own code block.
2. The action function, with its return type made fully explicit.
3. A short note on which useState calls were removed and precisely what replaced each one.
4. If useOptimistic was not added, one sentence confirming why it was correctly left out given the stated need.Customize
Optional — swap in your own details for the highlighted parts above.
Why this works
The child-component rule for useFormStatus is load-bearing, not stylistic: React's own documentation is explicit that useFormStatus must be called from a component rendered inside the form, and calling it in the same component that renders the form tag itself returns the default, always-false pending state — a bug that passes a quick manual click-test because the developer usually tests the happy path once, and then silently never shows a pending state in production under real network latency. Collapsing three separate useState calls into one useActionState call removes an entire class of bug where isSubmitting, error, and the actual in-flight request drift out of sync — a fetch that resolves after a component has already re-rendered for an unrelated reason, for instance — because the pending state now comes directly from the transition React itself is tracking, not from a boolean a developer has to remember to flip back in every code path, including the error path that's easy to forget. Restricting useOptimistic to cases where the interface genuinely renders a result before confirmation stops the common mistake of adding it to a form where nothing is actually shown optimistically, which adds real indirection — a second, temporary state to reason about — for zero visible benefit, and the requirement to justify not using it forces that judgment call to be explicit rather than defaulted into either direction. The progressive-enhancement note is not a theoretical nicety: React 19's form action prop is specifically designed so a submission can proceed as a real HTTP navigation before hydration completes, a meaningfully different guarantee than the onSubmit-plus-preventDefault pattern it replaces, and a migration that keeps an onClick handler intercepting the submit defeats that guarantee silently, leaving the app no better off on a slow connection than before the migration despite now using the newer API. Triggering the post-success reset from the action's own returned state, rather than manually clearing each field, matters because a hand-maintained reset list drifts the same way a hand-maintained validation list does — a field added to the form later is easy to forget adding to the reset call, and the resulting bug, a stale value lingering after a supposedly successful and cleared submission, is exactly the kind of thing that only surfaces when a real user reports it, not during a quick manual test of the one field the developer happened to check.
What you get back
async function subscribeAction(prevState, formData) { const email = formData.get('email'); if (!email || !/^[^@]+@[^@]+\.[^@]+$/.test(String(email))) { return { error: 'Enter a valid email address.' }; } const res = await fetch('/api/subscribe', { method: 'POST', body: formData }); if (!res.ok) return { error: 'Something went wrong — try again.' }; return { error: null }; } function SubmitButton() { const { pending } = useFormStatus(); return <button disabled={pending}>{pending ? 'Subscribing…' : 'Subscribe'}</button>; } Removed: isSubmitting, replaced by useFormStatus's pending, read only inside SubmitButton. error, replaced by useActionState's returned state.error. success, replaced by checking state.error === null after a submission has actually occurred. useOptimistic correctly omitted: this form has no list or counter to update ahead of confirmation — there is nothing to show optimistically.
Verified against
Claude Code Sonnet 4.6 · 2026-07-25
Cursor Cursor 2.1 · 2026-07-27
Changelog
- 2026-07-25 — Initial publish, verified against Claude Code (Sonnet 4.6) and Cursor 2.1 on React 19.
Need this built into your business?
If a prompt isn't enough — custom software, built and maintained for you — that's Scult's day job.
EXPLORE CUSTOM SOFTWARE
