Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

Forms with actions

React 19 gave forms a new way to submit: you can pass a function to the action attribute. React collects the field values into a FormData object, calls the function and clears the fields once it finishes successfully. For the pending state and the result there is the useActionState hook. You do not need state for every field.

A function in action

Let us add a new post form to the blog. Create src/pages/NewPostPage.jsx:

// src/pages/NewPostPage.jsx
export default function NewPostPage() {
  function createPost(formData) {
    console.log('Title:', formData.get('title'));
  }
 
  return (
    <form action={createPost}>
      <input name="title" placeholder="Title" />
      <button type="submit">Publish</button>
    </form>
  );
}

Add the route <Route path="posts/new" element={<NewPostPage />} /> to src/App.jsx and open /posts/new. After submitting, the console shows the typed title, the page does not reload and the field is cleared. The value is read by the field's name attribute.

useActionState: result and pending

The useActionState hook takes an action function and an initial state. It returns the current state, a function for action and a pending flag:

// src/pages/NewPostPage.jsx
import { useActionState } from 'react';
 
async function createPost(prevState, formData) {
  const title = formData.get('title');
  if (!title) {
    return { error: 'Enter a title' };
  }
 
  const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, body: formData.get('body'), userId: 1 }),
  });
  const post = await response.json();
 
  return { message: `Post created, number ${post.id}` };
}
 
export default function NewPostPage() {
  const [state, formAction, isPending] = useActionState(createPost, {});
 
  return (
    <form action={formAction}>
      <input name="title" placeholder="Title" />
      <textarea name="body" placeholder="Text" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending…' : 'Publish'}
      </button>
      {state.error && <p>{state.error}</p>}
      {state.message && <p>{state.message}</p>}
    </form>
  );
}

The action function receives the previous state first and the form data second, and whatever it returns becomes the new state. Submit an empty form — "Enter a title" appears. Fill in the title — the button shows "Sending…" during the request, then "Post created, number 101" appears. JSONPlaceholder only pretends to create posts and always answers with number 101.

useFormStatus: the status in a child component

It is handy to move the submit button into its own component. It can find out whether the form is submitting with the useFormStatus hook from the react-dom package:

// src/components/SubmitButton.jsx
import { useFormStatus } from 'react-dom';
 
export default function SubmitButton({ children }) {
  const { pending } = useFormStatus();
 
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Sending…' : children}
    </button>
  );
}

In the form: <SubmitButton>Publish</SubmitButton>. The button disables itself while the form is submitting.

Actions or onSubmit

onSubmit and field stateaction and useActionState
Input must be checked while typingChecking on submit is enough
Fields depend on each otherFields are independent
Works in any React versionNeeds React 19

Common mistake

useFormStatus reports only on the form the component sits inside. If you call the hook in the component that renders the form itself, pending is always false:

// Wrong: the form is below, in this same component's markup.
export default function NewPostPage() {
  const { pending } = useFormStatus();
  return <form action={createPost}>...</form>;
}

Call useFormStatus in a child component inside form, like SubmitButton above, or take isPending from useActionState.

Thanks for reading!

Donate to the site — we work hard for you.