Skip to main content

Building Forms Without Reducers: Step-by-Step

For years, building robust forms in React meant choosing between scattered useState calls or a complex useReducer. React 19's useActionState changes that: you can now build forms that handle loading, validation, and errors with zero reducer boilerplate. The shift from imperative state machines to declarative actions makes form code easier to read, test, and maintain.

In React 18, a typical form might have 5–10 lines of state logic per field plus error handling. With useActionState, that collapses to a single hook call and an action function. This article teaches you the step-by-step pattern for converting traditional form logic to the modern approach, starting with simple single-field forms and scaling to multi-field validations.

Single-Field Form: The Basic Pattern

Start with the simplest case: a form with one text input. Here's the old way (React 18):

// React 18 pattern
import { useState } from "react";

export default function NameForm() {
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/users", {
method: "POST",
body: JSON.stringify({ name })
});
if (!res.ok) throw new Error("Failed to submit");
// Handle success
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}

return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button disabled={loading}>{loading ? "Saving..." : "Save"}</button>
{error && <p style={{ color: "red" }}>{error}</p>}
</form>
);
}

Here's the React 19 way using useActionState:

// React 19 pattern
import { useActionState } from "react";

async function saveName(prevState, formData) {
const name = formData.get("name");

try {
const res = await fetch("/api/users", {
method: "POST",
body: JSON.stringify({ name })
});
if (!res.ok) throw new Error("Failed to submit");
return { success: true, name };
} catch (err) {
return { error: err.message };
}
}

export default function NameForm() {
const [state, dispatch, pending] = useActionState(saveName, { error: null });

return (
<form action={dispatch}>
<input name="name" placeholder="Your name" disabled={pending} />
<button disabled={pending}>{pending ? "Saving..." : "Save"}</button>
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
{state.success && <p>Saved as {state.name}!</p>}
</form>
);
}

The differences: no onChange handlers, no manual state setters, no try-catch in the component. React wires up the form to the action via form action={dispatch}, and the action function handles the whole lifecycle.

Multi-Field Form with Validation

Now, a form with multiple fields. Without a reducer, you'd need useState for each field plus separate loading/error states. With useActionState, you validate within the action and return errors keyed by field:

async function submitProfile(prevState, formData) {
const firstName = formData.get("firstName");
const email = formData.get("email");
const errors = {};

// Validation
if (!firstName || firstName.trim().length < 2) {
errors.firstName = "First name must be at least 2 characters";
}
if (!email || !email.includes("@")) {
errors.email = "Valid email required";
}

if (Object.keys(errors).length > 0) {
return { errors, success: false };
}

// Server call
try {
const res = await fetch("/api/profile", {
method: "POST",
body: formData
});
if (!res.ok) throw new Error("Server error");
const data = await res.json();
return { success: true, profile: data };
} catch (err) {
return { error: err.message, success: false };
}
}

export default function ProfileForm() {
const [state, dispatch, pending] = useActionState(submitProfile, {
errors: {},
success: false,
error: null
});

return (
<form action={dispatch}>
<fieldset disabled={pending}>
<div>
<label>First Name</label>
<input name="firstName" required />
{state.errors?.firstName && (
<p style={{ color: "red" }}>{state.errors.firstName}</p>
)}
</div>

<div>
<label>Email</label>
<input name="email" type="email" required />
{state.errors?.email && (
<p style={{ color: "red" }}>{state.errors.email}</p>
)}
</div>

<button type="submit">
{pending ? "Saving..." : "Save Profile"}
</button>
</fieldset>

{state.error && <p style={{ color: "red" }}>{state.error}</p>}
{state.success && <p>Profile updated!</p>}
</form>
);
}

The pattern: the action validates all fields, returns an object with { errors: {...}, success }, and the component displays errors per field. No reducer, no separate state for each field, no manual event handlers.

Initializing State from Server Data

Often, you load initial data from the server (e.g., edit form pre-fills with the user's current data). Pass that as the initial state to useActionState:

async function updateProfile(prevState, formData) {
const email = formData.get("email");

try {
const res = await fetch(`/api/users/${prevState.userId}`, {
method: "PATCH",
body: formData
});
if (!res.ok) throw new Error("Failed to update");
const updated = await res.json();
return { ...prevState, profile: updated, success: true };
} catch (err) {
return { ...prevState, error: err.message, success: false };
}
}

export default function EditProfileForm({ initialUser }) {
const [state, dispatch, pending] = useActionState(updateProfile, {
userId: initialUser.id,
profile: initialUser,
success: false,
error: null
});

return (
<form action={dispatch}>
<input
name="email"
type="email"
defaultValue={state.profile?.email}
disabled={pending}
/>
<button disabled={pending}>Update</button>
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
{state.success && <p>Updated!</p>}
</form>
);
}

Here, the initial state includes the user ID and current profile data. The action uses prevState to access the user ID for the API call, and returns the updated state. The input uses defaultValue (not value) so the form controls the input, not React.

Reusing Actions Across Multiple Forms

An action is just a function. You can reuse it across multiple form components or contexts:

// utils/actions.js
export async function saveMessage(prevState, formData) {
const message = formData.get("message");
if (!message) {
return { error: "Message cannot be empty" };
}
try {
const res = await fetch("/api/messages", {
method: "POST",
body: formData
});
if (!res.ok) throw new Error("Failed to save");
const data = await res.json();
return { success: true, messageId: data.id };
} catch (err) {
return { error: err.message };
}
}
// components/ChatForm.js
import { useActionState } from "react";
import { saveMessage } from "../utils/actions";

export default function ChatForm() {
const [state, dispatch, pending] = useActionState(saveMessage, {});
return (
<form action={dispatch}>
<textarea name="message" disabled={pending} />
<button disabled={pending}>Send</button>
{state.error && <p>{state.error}</p>}
</form>
);
}
// components/CommentForm.js
import { useActionState } from "react";
import { saveMessage } from "../utils/actions";

export default function CommentForm() {
const [state, dispatch, pending] = useActionState(saveMessage, {});
return (
<form action={dispatch}>
<input name="message" placeholder="Your comment" disabled={pending} />
<button disabled={pending}>Post</button>
{state.error && <p>{state.error}</p>}
</form>
);
}

One action, two forms. This promotes consistency and reduces code duplication.

Key Takeaways

  • useActionState eliminates the need for separate useState calls for loading, error, and data—use a single hook.
  • Write validation and error handling inside the action function; return { errors: {...} } and let the component display them.
  • Use form action={dispatch} instead of onSubmit handlers to bind the form to the action.
  • Initialize state with useActionState(action, initialState) to pre-fill forms or pass metadata (user ID, etc.).
  • Reuse actions across multiple forms to keep logic DRY and consistent.

Frequently Asked Questions

How do I handle form reset after successful submission?

Return a success state, and use a useEffect to reset the form:

const [state, dispatch] = useActionState(action, {});
const formRef = useRef();

useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state.success]);

return <form ref={formRef} action={dispatch}>...</form>;

Can I clear the form manually before submission?

Yes. Call formRef.current?.reset() before submission, or return a new initial state from the action.

How do I handle file uploads with useActionState?

Include <input type="file" name="file" /> in the form. formData.get("file") in the action returns a File object. Send it via FormData or read it as needed.

What if I need to run multiple actions sequentially?

Use async/await in the action. Each action is sequential by nature since it's one promise.

Can useActionState replace useReducer for complex state?

For most forms, yes. But if you have deeply nested state or many interdependent transitions, useReducer may still be clearer. useActionState shines for form-focused state.

Further Reading