Are Form Libraries Dead? useActionState vs React Hook Form

By Odilon14 min read

Are Form Libraries Dead?#

Early in this blog's history I recommended React Hook Form as the go-to form library. "Just use it, it handles everything." And for four years, that was solid advice.

Then React 19 shipped useActionState and useFormStatus. On a new project I decided to try building forms with just the built-in primitives — no React Hook Form, no Formik, just React.

Three months later, I've removed React Hook Form from three separate projects.

So — are form libraries dead?

The Short Answer#

No. But the bar for when you need one got a lot higher.

What useActionState Actually Does#

useActionState wraps a server action (or any async function with the right signature) and gives you three things:

tsx
const [state, formAction, isPending] = useActionState(serverAction, initialState);
  • state — whatever the last call to serverAction returned, or initialState if it hasn't been called
  • formAction — a function you pass to <form action={...}>
  • isPending — true while the action is running

Here's a complete real-world example — an organization settings form:

tsx
// infrastructure/actions/organization-actions.ts
"use server";

import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getMongoDb } from "@/lib/infrastructure/mongodb";

const UpdateOrgSchema = z.object({
  name: z.string().min(2, "Organization name must be at least 2 characters"),
  website: z.string().url("Must be a valid URL").optional().or(z.literal("")),
  timezone: z.string().min(1, "Timezone is required"),
});

export type UpdateOrgState = {
  error: string | null;
  fieldErrors: Partial<Record<"name" | "website" | "timezone", string>>;
  success: boolean;
};

export async function updateOrganizationAction(
  _prevState: UpdateOrgState,
  formData: FormData
): Promise<UpdateOrgState> {
  const session = await auth();
  if (!session?.user?.organizationId) {
    return { error: "Not authenticated", fieldErrors: {}, success: false };
  }

  const parsed = UpdateOrgSchema.safeParse({
    name: formData.get("name"),
    website: formData.get("website") || "",
    timezone: formData.get("timezone"),
  });

  if (!parsed.success) {
    const fieldErrors: UpdateOrgState["fieldErrors"] = {};
    for (const issue of parsed.error.issues) {
      const field = issue.path[0] as keyof UpdateOrgState["fieldErrors"];
      fieldErrors[field] = issue.message;
    }
    return { error: null, fieldErrors, success: false };
  }

  try {
    const db = await getMongoDb();
    await db.collection("organizations").updateOne(
      { _id: session.user.organizationId },
      { $set: { ...parsed.data, updatedAt: new Date() } }
    );
    revalidatePath("/dashboard/settings");
    return { error: null, fieldErrors: {}, success: true };
  } catch {
    return { error: "Failed to save settings. Please try again.", fieldErrors: {}, success: false };
  }
}
tsx
// infrastructure/ui/organization-settings-form.tsx
"use client";

import { useActionState } from "react";
import { updateOrganizationAction, type UpdateOrgState } from "../actions/organization-actions";

const initialState: UpdateOrgState = {
  error: null,
  fieldErrors: {},
  success: false,
};

interface OrganizationSettingsFormProps {
  organization: {
    name: string;
    website: string | null;
    timezone: string;
  };
}

export function OrganizationSettingsForm({ organization }: OrganizationSettingsFormProps) {
  const [state, formAction, isPending] = useActionState(
    updateOrganizationAction,
    initialState
  );

  return (
    <form action={formAction} className="space-y-6 max-w-lg">
      {state.success && (
        <div className="rounded-md bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-800">
          Settings saved successfully.
        </div>
      )}
      {state.error && (
        <div className="rounded-md bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
          {state.error}
        </div>
      )}

      <div>
        <label htmlFor="name" className="block text-sm font-medium mb-1">
          Organization Name
        </label>
        <input
          id="name"
          name="name"
          type="text"
          defaultValue={organization.name}
          className={`w-full rounded-md border px-3 py-2 ${state.fieldErrors.name ? "border-red-400" : "border-gray-300"}`}
          aria-describedby={state.fieldErrors.name ? "name-error" : undefined}
          aria-invalid={state.fieldErrors.name ? "true" : undefined}
        />
        {state.fieldErrors.name && (
          <p id="name-error" className="mt-1 text-sm text-red-600">
            {state.fieldErrors.name}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="website" className="block text-sm font-medium mb-1">
          Website <span className="text-gray-400 font-normal">(optional)</span>
        </label>
        <input
          id="website"
          name="website"
          type="url"
          defaultValue={organization.website ?? ""}
          className={`w-full rounded-md border px-3 py-2 ${state.fieldErrors.website ? "border-red-400" : "border-gray-300"}`}
          aria-describedby={state.fieldErrors.website ? "website-error" : undefined}
          aria-invalid={state.fieldErrors.website ? "true" : undefined}
        />
        {state.fieldErrors.website && (
          <p id="website-error" className="mt-1 text-sm text-red-600">
            {state.fieldErrors.website}
          </p>
        )}
      </div>

      <SubmitButton isPending={isPending} />
    </form>
  );
}

function SubmitButton({ isPending }: { isPending: boolean }) {
  return (
    <button
      type="submit"
      disabled={isPending}
      className="rounded-md bg-blue-600 px-5 py-2 text-white font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
    >
      {isPending ? "Saving..." : "Save Changes"}
    </button>
  );
}

Look at what this handles: submission, pending state, field-level errors, success message. No library. No custom hook bridging Zod and React Hook Form. The server action does validation and the form renders whatever state it returns.

My React Hook Form version of this same form was about 80 lines longer and required a zodResolver import, a useForm call with a generic type parameter, field registration with ...register("name"), and an onSubmit handler that manually called the server action. All that infrastructure is just... gone.

useFormStatus: The Nested Pending State Problem#

useFormStatus reads the pending state of the nearest parent <form>. It's useful when your submit button is buried deep in the tree:

tsx
"use client";

import { useFormStatus } from "react-dom";

// drop this inside any form — it knows when the form is submitting
function FormSubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className="rounded-md bg-blue-600 px-5 py-2 text-white font-medium disabled:opacity-50"
    >
      {pending ? "Loading..." : children}
    </button>
  );
}
tsx
// Use it anywhere:
<form action={someAction}>
  <input name="email" type="email" />
  <FormSubmitButton>Subscribe</FormSubmitButton>
</form>

Gotcha: useFormStatus only works inside a child component of the form. It doesn't work in the same component that renders the <form>. That's why the submit button is a separate component. Read the docs carefully on this one.

Progressive Enhancement (The Underrated Part)#

Here's something most people skip over: forms with server actions work without JavaScript. The action attribute on <form> is native HTML. When JS loads, React intercepts. When it doesn't load (or is still loading), the browser submits natively.

tsx
// works with or without JavaScript loaded
<form action={createPostAction}>
  <input name="title" type="text" required />
  <textarea name="content" required />
  <button type="submit">Publish Post</button>
</form>

This matters more than I initially thought. Mobile networks are unreliable. JS bundles sometimes fail to load. A form that degrades gracefully is objectively better than one that silently breaks. After four years of e.preventDefault() + manual fetch calls, having progressive enhancement for free feels almost unfair.

The Real Question: When Do You Still Need React Hook Form?#

Here's my honest breakdown after three months:

useActionState + useFormStatus are enough when:

  • The form submits to a server action
  • Validation lives on the server (Zod in the action)
  • 2-6 fields with straightforward validation
  • Simple error display (inline per-field + top-level)
  • No real-time validation as the user types

React Hook Form is still worth it when:

  • Multi-step wizard flow
  • Real-time validation (validate on blur, show errors as user types)
  • Dependent validation (field B's rules depend on field A's value)
  • Complex conditional display logic with many fields
  • Integration with third-party input components that need ref management

The honest truth? Most CRUD forms in a business app fall into the first category. Settings forms, create-entity forms, profile edit forms — none of these need a form library with the new primitives. Three months into building a developer portal with React 19, React Hook Form has not been needed once.

Optimistic Updates With useOptimistic#

React 19 also shipped useOptimistic, which pairs naturally with server actions:

tsx
"use client";

import { useOptimistic, useActionState } from "react";
import { togglePostPublished } from "../actions/post-actions";

function PostListItem({ post }: { post: Post }) {
  const [optimisticPost, setOptimisticPublished] = useOptimistic(
    post,
    (currentPost, newPublishedValue: boolean) => ({
      ...currentPost,
      isPublished: newPublishedValue,
    })
  );

  const [, formAction] = useActionState(
    async (_prev: unknown, formData: FormData) => {
      const newValue = !optimisticPost.isPublished;
      setOptimisticPublished(newValue);
      return togglePostPublished(post.id, newValue);
    },
    null
  );

  return (
    <div className="flex items-center justify-between p-4 border rounded-lg">
      <span className="font-medium">{optimisticPost.title}</span>
      <form action={formAction}>
        <button
          type="submit"
          className={`rounded-full px-3 py-1 text-sm font-medium ${
            optimisticPost.isPublished
              ? "bg-green-100 text-green-800"
              : "bg-gray-100 text-gray-600"
          }`}
        >
          {optimisticPost.isPublished ? "Published" : "Draft"}
        </button>
      </form>
    </div>
  );
}

Toggle responds immediately. No spinner. If the server call fails, React reverts automatically. This used to require a lot of manual state management. Now it's built in.

My Take#

I've removed React Hook Form from three projects in the past two months. Not because it's bad — it's excellent software, and I still recommend it for complex forms. But it was solving problems I no longer had.

The key insight: when validation moves to the server action and the form just displays whatever state the action returns, the client-side form management problem mostly disappears. You don't need a library to track field state when the form is uncontrolled and the source of truth is the server action's return value.

Start with useActionState + useFormStatus. Add React Hook Form when you hit a requirement they can't meet. You'll be surprised how rarely that happens for standard business CRUD.

The Prediction#

And here's where I speculate, so take this with appropriate skepticism:

I think within 18 months, form libraries will primarily serve two niches: (1) complex multi-step wizards with rich client-side validation, and (2) apps that don't use server actions (pure client-side SPAs). For the standard Next.js server-action-backed CRUD form — which is probably 70% of all forms written in React today — the built-in primitives will become the default.

React Hook Form won't die. But I think its install count will plateau, and eventually decline, as the React 19 patterns become the path of least resistance. The same way jQuery didn't die overnight but gradually became unnecessary as browsers got better.

Could I be wrong? Absolutely. I thought Redux would be dead by 2022 and here we are. But the trajectory feels clear to me.

TL;DR: useActionState + useFormStatus handle the full lifecycle of a server-action-backed form — submission, pending state, field errors, success handling — with zero dependencies. They're not experimental; they shipped stable in React 19. For most CRUD forms, they're enough. For complex forms with multi-step flows, real-time validation, or rich client-side state management, React Hook Form is still the right call. Start without it. Add it when you need it.

Are Form Libraries Dead? useActionState vs React Hook Form | Blog