- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Guided: Building Forms with React 19 Actions
In this lab, you'll build React 19 forms that use Actions instead of hand-written submit handlers. You'll return validation errors and results directly from a form Action, give users clear feedback while a submission is in progress, and keep the form responsive when a save fails. You'll come away with a cleaner, repeatable pattern for building forms in modern React.
Lab Info
Table of Contents
-
Challenge
Introduction
Welcome to the Guided: Building Forms with React 19 Actions lab.
Forms are where React components tend to collect the most bookkeeping: a flag for pending, an object for errors, a state hook for every field, and a submit handler that stitches them together. React 19 Actions move that work into the form itself, so your component describes what a submission produces instead of managing every intermediate state by hand.
In this lab, you'll modernize a Globomanitcs feature-request board, a TypeScript form where employees propose improvements to company tools. Its submit handler has three problems you'll fix:
- It tracks the request list, field values, errors, and a pending flag in separate
useStatehooks. - Its submit button can be clicked again while a save is still running.
- A failed save silently clears every field.
By the end of the lab, the board will:
- submit through a form Action that returns errors and results as state
- keep the user's input when a save fails
- use a submit button that reports its own pending state
- show new requests instantly with an optimistic update
info> Feeling stuck? Check out the matching
solution/stepN/folder for the step you're on to see a working implementation.(Note Globomanitcs is fictional, and it has no affiliation with or endorsement by any real company.)
- It tracks the request list, field values, errors, and a pending flag in separate
-
Challenge
Step 2: Define the form's Action state
Here, you'll define the single state object that the form Action returns after every submission. By the end of this step, the board has one typed shape for everything a submission can produce, plus the value it starts from.
The lab's codebase lives in the
applicationdirectory, which already contains the board, a simulated save API, a validation helper, and scaffold files withTODOcomments marking where you'll work.A form Action returns one value after each submission, and React hands that value back to your component as state. In this lab, that value is a
BoardStateobject, and it holds everything the board shows after a submission:requests: the feature requests that saved successfullyerrors: the validation message for each invalid fieldvalues: the values the user submittedmessage: a save-failure message, ornullwhen there isn't one
Because every submission produces a complete
BoardState, the board can render from one object instead of four separate hooks. TheinitialBoardStatevalue is what the board shows before anyone submits. -
Challenge
Step 3: Convert the form to an Action with useActionState
With
BoardStatedefined, the board has one shape for everything a submission can produce, and your Action's job is to build that shape. Here, you'll writesubmitFeatureRequestso it validates the submitted values, saves valid requests, and turns failures into state, then hand it to the board throughuseActionState. By the end of this step, submitting the form runs your Action and React tracks the pending state for you, with noonSubmithandler.To read the form, use the two arguments a form Action receives: the previous state and a
FormDataobject that holds every named field in the form. In TypeScript,formData.get()returnsFormDataEntryValue | nullrather than astring, because a field can be missing and a file input returns aFile, so convert each value before storing it.Hint: how to read one FormData field as a string
const label = String(data.get('fieldName') ?? '');To validate, use the provided
validateFeatureRequesthelper. It returns an object with one message per invalid field, and an empty object means the input is valid. Return those errors as state, together with the submitted values, and clear any earliermessageso an old save failure doesn't linger beside new field errors.To save, keep in mind that an Action should resolve with the next state even when the save fails. If it throws instead, the error escapes to the nearest error boundary rather than becoming a message the board can show. Wrap the save in
try/catchand return a failure state from thecatchblock, keeping the user's values and the existing requests. In strict mode, TypeScript types the caught value asunknown, because JavaScript can throw any value, so check that it's anErrorbefore reading itsmessage.Hint: how to read a message from an unknown caught value
const text = err instanceof Error ? err.message : 'Fallback message';To connect the board, use
useActionState. It takes an Action and an initial state, and returns three values:- the current state, which starts as the initial state
- a dispatch function that you pass to the form's
actionprop - an
isPendingflag that istruewhile the Action runs
When a form's
actionprop is a function, React collects theFormData, calls your Action with the previous state, and stores the result, so you no longer needonSubmitorevent.preventDefault(). The title, description, and priority inputs keep their ownuseStatehooks until the next step, so their text stays in place after each submission for now.const [result, runAction, busy] = useActionState(someAction, startingValue); -
Challenge
Step 4: Show inline errors and preserve input
Your Action now returns errors, failure messages, and the user's values, but the board still shows errors only in a summary list and says nothing when a save fails. Here, you'll put each error beside its field, show the failure message, and switch the inputs so they take their starting value from the returned state. By the end of this step, a user can fix a mistake or retry a failed save without retyping the request.
An error next to its field tells the user exactly what to fix, while a summary list makes them match each message to a field. Render each field's entry from
state.errorsdirectly beneath that field, in an element with an ID such asfieldname-error. A save failure isn't tied to one field, so renderstate.messageabove the form in an element withrole="alert", which screen readers announce as soon as it appears. The description and priority error blocks are already in the form, commented out and markedTODO (Task 4.1), and they show the conditional-rendering pattern you'll use for the title.To preserve input, rely on what React does after a form Action completes without throwing: it resets the form, returning each uncontrolled field to its
defaultValue. Your Action already returns what the user typed instate.values, so using those values as each field'sdefaultValueputs the input right back after a failed or invalid submission and clears it after a successful one. A<select>doesn't pick up a changeddefaultValueafter that reset, so also give it akeyfrom the same value: when the key changes, React replaces the element and applies the new default. With this pattern, the field-value hooks andonChangehandlers are no longer needed.<select key={saved.choice} defaultValue={saved.choice}>{/* options */}</select> -
Challenge
Step 5: Build a reusable submit button with useFormStatus
The board now validates, saves, recovers from failure, and preserves input through the Action state, and its header already uses
isPendingto show that a save is underway. Here, you'll complete aSubmitButtoncomponent that reads the status of the form it sits in, so it can disable itself and show a loading label without receiving any props. By the end of this step, double submissions are no longer possible.useFormStatus, imported fromreact-dom, returns an object whosependingproperty istruewhile the nearest parent<form>runs its Action. It only reads a form it's rendered inside, so you call it from a child component such as a button, not from the component that renders the<form>. That's the difference fromisPending:isPendingbelongs to the component that calleduseActionState, whileuseFormStatusworks inside any form, which makes the button reusable. -
Challenge
Step 6: Show new requests optimistically with useOptimistic
The form now gives clear feedback during every save, but a new request still appears in the list only after the 1.5-second delay. Here, you'll use
useOptimisticto show each submitted request immediately with a(saving)marker, and let React remove it automatically if the save fails. By the end of this step, the board feels instant while still showing only what actually saved.useOptimistictakes the real state and an update function, and returns two values: the value to display, and a function that adds a temporary update. While an Action is pending, the displayed value includes those temporary updates. When the Action finishes, React discards them and shows the real state again, so a failed save rolls back without any cleanup code.const [shownValue, addTemporary] = useOptimistic(realValue, (current, incoming) => combine(current, incoming));To add an entry, call the add function inside an Action. React shows an optimistic update only while an Action is pending, and it warns if the add function runs outside one, so the call belongs inside the function you pass to
useActionState. WrapsubmitFeatureRequestin a small async function that adds the entry and then delegates to it, and give the wrapper's parameters explicit types, because strict mode can't infer them there. The wrapper callsaddOptimisticRequest, which is declared belowuseActionState; this works because the wrapper runs only when the form is submitted, after the component has rendered. The project providescreatePendingRequest(formData)insrc/pendingRequest.ts, which builds the temporary entry with a temporary ID and thependingflag that makes the list show(saving). ### Run the finished boardWith every task complete, you can try the board in a browser.
- In Explorer, expand the
.instructionsfolder. - Right-click
lab-layout-0.0.13.vsix, and select Install Extension VSIX.- You'll likely need to allow pop-ups in your browser to do this.
- A Terminal and a Simple Browser tab will appear.
- In the Terminal that appears, enter
cd ~/workspace/application/. - Then run the following:
npm run devYou'll see a message containing
VITEin the Terminal. (Ignore the URLs there, as they won't work in-lab.) - Go the Simple Browser VS Code tab to view the board. You'll likely need to click the Simple Browser's Reload button.
Try these submissions:
- With the form empty, select Submit request. The title and description errors appear beneath their fields right away.
- Enter a title that contains the word
fail, add a description, choose High, and select Submit request. The request appears in the list with a(saving)marker, the button showsSubmitting...and stays disabled, and the header showsSaving.... After about 1.5 seconds, the entry disappears and an alert readsSave failed: the request service is unavailable., with all three fields still filled in. - Change the title so it no longer contains
fail, and select Submit request. The request appears immediately with(saving), the marker disappears when the save completes, and the form clears with the priority back to Medium.
Congratulations on completing the lab.
You rebuilt the feature-request board around a React 19 form Action. The Action now returns validation errors, saved requests, and failure messages as state,
SubmitButtonreports its own pending status, and new requests appear instantly before rolling back when a save fails, as you saw in the running board.Along the way, you practiced returning results from an Action instead of managing separate hooks, preserving input with
defaultValue, scoping pending state withuseFormStatus, and layering optimistic updates over real state. You can apply these patterns to any form where the result of a submission should drive what the user sees. - In Explorer, expand the
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.