JavaScript and React course Β· Module 9: Modern React Hooks

useFormStatus and useActionState - React 19 form hooks

9 min read
In this lesson6

On our cosmic journey through the galaxies of React, forms are one of the most important communication modules of the ship. Every mission report, every command for the navigation system and every message to the base goes through a form. Until now that meant a manual onSubmit, e.preventDefault(), a separate loading state and another one for errors. React 19 introduces form actions and two hooks, useFormStatus and useActionState, which take over most of that work.

Server Actions - a new paradigm

Before we explore the new hooks, we need to understand the concept of Server Actions. These are functions marked with the 'use server' directive that execute on the server, but can be called straight from a form in a client component. The directive only works in a framework with React Server Components, for example Next.js with the App Router, because something has to run the function on the server side. A file with actions looks like this:

1// actions.js
2'use server';
3
4export async function submitMissionReport(formData) {
5  const missionName = formData.get('missionName');
6  const status = formData.get('status');
7
8  // This executes on the SERVER
9  await database.missions.create({
10    name: missionName,
11    status: status,
12    timestamp: new Date()
13  });
14
15  return { success: true, message: 'Report saved!' };
16}

The function is exported, because the component imports it like any module. The framework does not send its code to the browser, only a reference the form uses to call the server. You pass a Server Action directly to the action attribute of a form:

1// MissionForm.jsx
2import { submitMissionReport } from './actions';
3
4function MissionForm() {
5  return (
6    <form action={submitMissionReport}>
7      <input name="missionName" placeholder="Mission name" />
8      <select name="status">
9        <option value="active">Active</option>
10        <option value="completed">Completed</option>
11      </select>
12      <button type="submit">Submit report</button>
13    </form>
14  );
15}

You write no API endpoint and no onSubmit handler: React collects the form fields into a FormData object and passes it to the action. Important: a form action can also be a plain async function defined in a client component, without 'use server'. That is how the next examples and the editor in this lesson work, because useFormStatus and useActionState support both kinds of actions.

useFormStatus - form status in child components

useFormStatus is a hook from the react-dom package that returns information about the form submission state. Key rule: it must be used in a child component of a <form> element, not in the component that renders the form.

1import { useFormStatus } from 'react-dom';
2
3// Submit button component - INSIDE the form
4function SubmitButton() {
5  const { pending, data, method, action } = useFormStatus();
6
7  return (
8    <button
9      type="submit"
10      disabled={pending}
11      style={{
12        padding: '10px 20px',
13        background: pending ? '#333' : '#00d4ff',
14        color: pending ? '#666' : '#0a0e17',
15        border: 'none',
16        borderRadius: '6px',
17        cursor: pending ? 'wait' : 'pointer'
18      }}
19    >
20      {pending ? 'Sending report...' : 'Submit mission report'}
21    </button>
22  );
23}
24
25// pending - true when the form is being submitted
26// data - FormData object with the data of the submitted form
27// method - the form method: 'get' or 'post'
28// action - the function passed to the action attribute

The button knows by itself that the form is being sent, so the parent does not have to pass it any state through props. When nothing is being sent, pending is false and the other fields are null.

Practical example with useFormStatus

The hook is useful not only in a button. Every component inside a form can ask about its state, so the fields disable themselves and an overlay shows a transmission message. The MissionReportForm form passes them no state props at all:

1import { useFormStatus } from 'react-dom';
2
3function MissionInput({ name, label, placeholder }) {
4  const { pending } = useFormStatus();
5  return (
6    <div style={{ marginBottom: '12px' }}>
7      <label style={{ color: '#8899aa', display: 'block', marginBottom: '4px' }}>
8        {label}
9      </label>
10      <input
11        name={name}
12        placeholder={placeholder}
13        disabled={pending}
14        style={{
15          width: '100%',
16          padding: '8px 12px',
17          background: pending ? '#1a1a2e' : '#0f1729',
18          color: '#e0e1dd',
19          border: '1px solid #2a3a5c',
20          borderRadius: '6px',
21          opacity: pending ? 0.5 : 1
22        }}
23      />
24    </div>
25  );
26}
27
28function LoadingOverlay() {
29  const { pending } = useFormStatus();
30  if (!pending) return null;
31  return (
32    <div style={{
33      position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
34      background: 'rgba(10, 14, 23, 0.7)',
35      display: 'flex', alignItems: 'center', justifyContent: 'center',
36      borderRadius: '8px'
37    }}>
38      <p style={{ color: '#00d4ff' }}>Transmission in progress...</p>
39    </div>
40  );
41}
42
43function MissionReportForm({ submitAction }) {
44  return (
45    <form action={submitAction} style={{ position: 'relative' }}>
46      <LoadingOverlay />
47      <MissionInput name="mission" label="Mission name" placeholder="E.g. Alpha Centauri" />
48      <MissionInput name="coordinates" label="Coordinates" placeholder="E.g. 14h 29m 43s" />
49      <SubmitButton />
50    </form>
51  );
52}

SubmitButton from the previous example works here unchanged. If you called useFormStatus in MissionReportForm itself, pending would always be false, because the hook reads the parent form, not the form the component renders.

useActionState - full action state management

useActionState (formerly useFormState from the react-dom package) is a hook from the react package that connects an action, server or plain, with state management. It returns the current state, a wrapped action and an isPending flag:

1import { useActionState } from 'react';
2
3// Action returning the new state: here a plain async function in the browser
4async function createMission(previousState, formData) {
5  const name = formData.get('name');
6  const destination = formData.get('destination');
7
8  if (!name || name.length < 3) {
9    return {
10      error: 'Mission name must be at least 3 characters',
11      success: false
12    };
13  }
14
15  // Simulation of saving to the database
16  await new Promise(resolve => setTimeout(resolve, 1000));
17
18  return {
19    success: true,
20    message: 'Mission created successfully!',
21    missionId: Math.random().toString(36).slice(2, 11)
22  };
23}
24
25function CreateMissionForm() {
26  const [state, formAction, isPending] = useActionState(
27    createMission,
28    { error: null, success: false } // Initial state
29  );
30
31  return (
32    <form action={formAction}>
33      <h2 style={{ color: '#00d4ff' }}>New space mission</h2>
34
35      <input name="name" placeholder="Mission name" />
36      <input name="destination" placeholder="Destination" />
37
38      <button type="submit" disabled={isPending}>
39        {isPending ? 'Creating mission...' : 'Create mission'}
40      </button>
41
42      {state.error && (
43        <p style={{ color: '#ff6b6b' }}>{state.error}</p>
44      )}
45
46      {state.success && (
47        <p style={{ color: '#00ff88' }}>{state.message} (ID: {state.missionId})</p>
48      )}
49    </form>
50  );
51}

The action gets the previous state as its first argument and the form data as the second. Whatever it returns becomes the new state, and isPending stays true until the action finishes. React queues further submissions, so every action gets the state returned by the previous one.

useActionState Parameters

The useActionState hook accepts three arguments:

  1. action - a function (previousState, formData) => newState, usually async
  2. initialState - the initial state (any value, most often an object)
  3. permalink (optional) - the address of the page with the form, needed for progressive enhancement

It returns an array with three elements:

  1. state - the current state (result of the last action or initialState)
  2. formAction - the wrapped action to pass to <form action>
  3. isPending - a boolean: whether the action is being executed

Progressive Enhancement

One of the greatest advantages of form actions is progressive enhancement: a form can work before JavaScript has loaded. There is one condition: the action must be a server function ('use server') and the page must be rendered on the server by a framework. Then the browser submits the form like a plain HTML form and the server runs the action. First the action:

1// search-actions.js
2'use server';
3
4export async function searchGalaxy(previousState, formData) {
5  const query = formData.get('query');
6  const results = await findStars(query);
7  return { results, query };
8}

A client component passes it to useActionState, and the third argument is the permalink, the address of the page with the form:

1// SearchForm.jsx
2'use client';
3import { useActionState } from 'react';
4import { searchGalaxy } from './search-actions';
5
6function SearchForm() {
7  const [state, searchAction, isPending] = useActionState(
8    searchGalaxy,
9    { results: [], query: '' },
10    '/search'
11  );
12
13  return (
14    <form action={searchAction}>
15      <input
16        name="query"
17        defaultValue={state.query}
18        placeholder="Search stars, planets, nebulae..."
19      />
20      <button type="submit" disabled={isPending}>
21        {isPending ? 'Scanning...' : 'Scan galaxy'}
22      </button>
23
24      {state.results.length > 0 && (
25        <ul>
26          {state.results.map(r => (
27            <li key={r.id}>{r.name} - {r.type}</li>
28          ))}
29        </ul>
30      )}
31    </form>
32  );
33}

If the user submits the form before hydration, the browser goes to /search and the server sends back the page with the result. Once JavaScript has loaded, React takes over the form and submits it without reloading the page. With the action written as a plain function in a client component, as in the previous examples, the form works only after JavaScript has loaded.

Form validation with useActionState

useActionState is perfect for server-side validation with immediate feedback:

1async function validateAndSubmit(prevState, formData) {
2  const errors = {};
3
4  const name = formData.get('crewName');
5  const role = formData.get('role');
6  const experience = parseInt(formData.get('experience'));
7
8  if (!name || name.length < 2) errors.crewName = 'Name must be at least 2 characters';
9  if (!role) errors.role = 'Select a role';
10  if (isNaN(experience) || experience < 0) errors.experience = 'Invalid experience';
11
12  if (Object.keys(errors).length > 0) {
13    return { errors, success: false, values: { name, role, experience } };
14  }
15
16  // saveCrewMember is your save function, e.g. a database request
17  await saveCrewMember({ name, role, experience });
18  return { errors: {}, success: true, message: 'Crew member added!' };
19}
20
21function CrewRegistrationForm() {
22  const [state, formAction, isPending] = useActionState(
23    validateAndSubmit,
24    { errors: {}, success: false }
25  );
26
27  return (
28    <form action={formAction}>
29      <input name="crewName" defaultValue={state.values?.name || ''} />
30      {state.errors?.crewName && <span style={{color:'red'}}>{state.errors.crewName}</span>}
31
32      <select name="role" defaultValue={state.values?.role || ''}>
33        <option value="">Select a role</option>
34        <option value="pilot">Pilot</option>
35        <option value="engineer">Engineer</option>
36        <option value="scientist">Scientist</option>
37      </select>
38      {state.errors?.role && <span style={{color:'red'}}>{state.errors.role}</span>}
39
40      <input name="experience" type="number" defaultValue={state.values?.experience || ''} />
41      {state.errors?.experience && <span style={{color:'red'}}>{state.errors.experience}</span>}
42
43      <button type="submit" disabled={isPending}>
44        {isPending ? 'Saving...' : 'Add to crew'}
45      </button>
46
47      {state.success && <p style={{color:'green'}}>{state.message}</p>}
48    </form>
49  );
50}

Notice the values in the response with errors. When the action finishes, React resets a form with uncontrolled fields, and every field goes back to its defaultValue. Because defaultValue reads the values from the state, after an error the user still sees what they typed, and after a successful save they get a clean form.

Summary

The new form hooks in React 19 are a fundamental change in form handling:

  1. useFormStatus - provides information about the form submission state in child components
  2. useActionState - connects an action with state management, validation and progressive enhancement
  3. Server Actions - server functions called directly from forms (in a framework with React Server Components)
  4. Progressive Enhancement - a form with a server action also works before JavaScript loads

These hooks eliminate a lot of form boilerplate: you no longer need a manual onSubmit, a useState for every field or a separate loading state. Remember: a form action is an order for mission control, and useActionState and useFormStatus report how far its execution has got.

Code for this lesson: App.jsx
1import { useActionState } from 'react';
2import { useFormStatus } from 'react-dom';
3
4// Form action. In Next.js it could be a server function with the 'use server' directive,
5// here it is a plain async function, so it runs in the browser.
6async function submitMissionReport(previousState, formData) {
7  // Simulated delay of the link to the base
8  await new Promise(resolve => setTimeout(resolve, 1500));
9  const name = formData.get('missionName');
10  const dest = formData.get('destination');
11  const priority = formData.get('priority');
12  const values = { missionName: name, destination: dest, priority };
13
14  // On an error we return the entered values: React resets the form after the action
15  if (!name || name.length < 3) {
16    return { ...previousState, error: 'Mission name must have at least 3 characters', success: false, values };
17  }
18  if (!dest) {
19    return { ...previousState, error: 'Provide a mission destination', success: false, values };
20  }
21
22  const entry = {
23    missionId: 'MSN-' + String(previousState.history.length + 1).padStart(3, '0'),
24    message: `Mission "${name}" to ${dest} (priority: ${priority}) created!`
25  };
26  // The history grows from action to action, because the action gets the previous state
27  return { error: null, success: true, values: {}, ...entry, history: [entry, ...previousState.history] };
28}
29
30// The button reads the form state through useFormStatus, without any props
31function SubmitButton() {
32  const { pending } = useFormStatus();
33  return (
34    <button
35      type="submit"
36      disabled={pending}
37      style={{
38        width: '100%', padding: '12px', marginTop: '12px',
39        background: pending ? '#1a2a3c' : 'linear-gradient(135deg, #00d4ff, #0088cc)',
40        color: pending ? '#556677' : '#0a0e17',
41        border: 'none', borderRadius: '8px', cursor: pending ? 'wait' : 'pointer',
42        fontSize: '14px', fontWeight: 'bold', transition: 'all 0.3s',
43      }}
44    >
45      {pending ? 'Transmission in progress...' : 'Submit mission report'}
46    </button>
47  );
48}
49
50// The field disables itself while the form is being sent
51function MissionInput({ name, label, placeholder, type = 'text', defaultValue }) {
52  const { pending } = useFormStatus();
53  const fieldStyle = {
54    width: '100%', padding: '10px 12px', background: '#0f1729',
55    color: '#e0e1dd', border: '1px solid #2a3a5c', borderRadius: '6px',
56    opacity: pending ? 0.5 : 1, boxSizing: 'border-box',
57  };
58  return (
59    <div style={{ marginBottom: '12px' }}>
60      <label htmlFor={name} style={{ color: '#8892b0', fontSize: '12px', display: 'block', marginBottom: '4px' }}>
61        {label}
62      </label>
63      {type === 'select' ? (
64        <select id={name} name={name} defaultValue={defaultValue || 'medium'} disabled={pending} style={fieldStyle}>
65          <option value="high">High</option>
66          <option value="medium">Medium</option>
67          <option value="low">Low</option>
68        </select>
69      ) : (
70        <input id={name} name={name} placeholder={placeholder} defaultValue={defaultValue || ''} disabled={pending} style={fieldStyle} />
71      )}
72    </div>
73  );
74}
75
76// Overlay visible only while sending
77function LoadingOverlay() {
78  const { pending } = useFormStatus();
79  if (!pending) return null;
80  return (
81    <div style={styles.overlay}>
82      <div style={styles.spinner} />
83      <p style={{ color: '#00d4ff', marginTop: '10px', fontSize: '13px' }}>Sending to the base...</p>
84    </div>
85  );
86}
87
88function MissionHistory({ missions }) {
89  if (missions.length === 0) return null;
90  return (
91    <div style={styles.history}>
92      <h3 style={{ color: '#00d4ff', fontSize: '14px', margin: '0 0 10px' }}>Mission history</h3>
93      {missions.map((m) => (
94        <div key={m.missionId} style={styles.historyItem}>
95          <span style={styles.missionId}>{m.missionId}</span>
96          <span>{m.message}</span>
97        </div>
98      ))}
99    </div>
100  );
101}
102
103function App() {
104  const [state, formAction] = useActionState(submitMissionReport, {
105    error: null, success: false, values: {}, history: []
106  });
107
108  return (
109    <div style={styles.container}>
110      <header style={styles.header}>
111        <h1 style={styles.title}>Mission Command Center</h1>
112        <p style={styles.subtitle}>useFormStatus and useActionState - React 19 form hooks</p>
113      </header>
114
115      <div style={styles.grid}>
116        <div style={styles.formPanel}>
117          <h2 style={{ color: '#00d4ff', fontSize: '16px', margin: '0 0 16px' }}>
118            New space mission
119          </h2>
120
121          <form action={formAction}>
122            <LoadingOverlay />
123            <MissionInput name="missionName" label="Mission name" placeholder="e.g. Proxima b exploration" defaultValue={state.values.missionName} />
124            <MissionInput name="destination" label="Destination" placeholder="e.g. Alpha Centauri" defaultValue={state.values.destination} />
125            <MissionInput name="priority" label="Priority" type="select" defaultValue={state.values.priority} />
126            <SubmitButton />
127          </form>
128
129          {state.error && (
130            <div role="alert" style={styles.error}>
131              <span style={{ marginRight: '8px' }}>!</span> {state.error}
132            </div>
133          )}
134          {state.success && (
135            <div style={styles.success}>
136              <span style={{ marginRight: '8px' }}>OK</span> {state.message}
137              <div style={{ fontSize: '11px', color: '#00aa66', marginTop: '4px' }}>
138                ID: {state.missionId}
139              </div>
140            </div>
141          )}
142        </div>
143
144        <MissionHistory missions={state.history} />
145      </div>
146
147      <div style={styles.info}>
148        <h3 style={{ color: '#ffd700', fontSize: '13px', margin: '0 0 8px' }}>How it works:</h3>
149        <ul style={{ margin: 0, paddingLeft: '20px', fontSize: '12px', color: '#8892b0' }}>
150          <li>useActionState connects an action with state and returns [state, formAction, isPending]</li>
151          <li>useFormStatus in the button, the fields and the overlay reads the form submission state</li>
152          <li>The action gets the previous state and FormData with the form fields</li>
153          <li>After the action React resets the fields, so on an error they come back through defaultValue</li>
154        </ul>
155      </div>
156    </div>
157  );
158}
159
160const styles = {
161  container: { fontFamily: 'system-ui', background: '#0a0e17', color: '#e0e1dd', minHeight: '100vh', padding: '20px' },
162  header: { textAlign: 'center', marginBottom: '20px' },
163  title: { fontSize: '22px', color: '#00d4ff', margin: '0 0 4px' },
164  subtitle: { fontSize: '12px', color: '#8892b0', margin: 0 },
165  grid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', maxWidth: '800px', margin: '0 auto' },
166  formPanel: { background: 'rgba(0,212,255,0.05)', borderRadius: '10px', padding: '20px', border: '1px solid rgba(0,212,255,0.15)', position: 'relative', overflow: 'hidden' },
167  overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(10,14,23,0.85)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', zIndex: 10, borderRadius: '10px' },
168  spinner: { width: '30px', height: '30px', border: '3px solid #1a3a5c', borderTopColor: '#00d4ff', borderRadius: '50%', animation: 'spin 1s linear infinite' },
169  error: { marginTop: '12px', padding: '10px 14px', background: 'rgba(255,107,107,0.1)', border: '1px solid rgba(255,107,107,0.3)', borderRadius: '6px', color: '#ff6b6b', fontSize: '13px' },
170  success: { marginTop: '12px', padding: '10px 14px', background: 'rgba(0,255,136,0.08)', border: '1px solid rgba(0,255,136,0.3)', borderRadius: '6px', color: '#00ff88', fontSize: '13px' },
171  history: { background: 'rgba(0,212,255,0.05)', borderRadius: '10px', padding: '16px', border: '1px solid rgba(0,212,255,0.15)' },
172  historyItem: { display: 'flex', gap: '8px', padding: '8px 10px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', marginBottom: '6px', fontSize: '12px', alignItems: 'center' },
173  missionId: { padding: '2px 8px', background: 'rgba(0,212,255,0.2)', borderRadius: '4px', color: '#00d4ff', fontSize: '10px', fontWeight: 'bold', whiteSpace: 'nowrap' },
174  info: { maxWidth: '800px', margin: '20px auto 0', padding: '14px', background: 'rgba(255,215,0,0.05)', border: '1px solid rgba(255,215,0,0.15)', borderRadius: '8px' },
175};
176
177export default App;

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Where must the useFormStatus hook be used to correctly read the form state?

  2. 2. What three values does the useActionState hook return?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    App.jsx contains a mission report form and an async action sendReport, which checks the mission name and answers after half a second, like the base on Earth. Fill in the blanks: ___BLANK1___ is the FormData method that reads a field value by its name, ___BLANK2___ is the React 19 hook that connects the action with the form state and returns [state, formAction, isPending], ___BLANK3___ is the <form> attribute you pass formAction to, and ___BLANK4___ is the value that disables the button while the form is being sent. A name shorter than 3 characters should show an error message, a valid one a confirmation with the mission name. Submit the form in the preview and check both cases.

  • Click in order

    Arrange the correct syntax for calling useActionState:

  • Click in order

    Arrange the correct syntax for a form with a Server Action:

  • Code editor

    App.jsx contains a crew member registration form (fields name, role and experience) and an action registerCrewMember, which checks the data on the "server side" and returns an errors object with a message for every invalid field. Fill in the blanks: ___BLANK1___ is the condition that is true when the name values.name has fewer than 2 characters, ___BLANK2___ is the object with the entered values that the action returns together with the errors, and ___BLANK3___ is the key in state.errors holding the message for the role field. React clears the form fields when the action finishes, so defaultValue restores them from the state. Invalid data should show a separate message next to every invalid field and keep the entered values, valid data a greeting with the name.

Useful articles