What’s New in React 19 — Are Forms Finally Good Enough? | Lakshmanshankar
Back to Blog
9 min read

What’s New in React 19

After a long wait since the alpha release, React 19 was officially released on December 5th, and this time we got some really cool improvements to forms. Here’s what dropped:

  • Improved async transitions (aka Actions).
  • Introducing Server Actions in Next.js.
  • The new use hook for suspending promises in components.
  • Improvements to RSC (React Server Components).
  • ref as a prop, forget about forwardRef and useImperativeHandle.
  • use context as provder No Context.Provider anymore.

But the big question is — do these improvements actually solve the pain of managing form state? If you’re a React developer, you know how frustrating it can be to handle state in complex forms. so let’s see how much simplified form handling is.


Are React Forms Good Enough?

React 19 introduced a lot of improvements to forms, those are

  1. The <form> element now has a new action attribute that is used to handle form submission by sending form data to the specified server function. Note: Server actions require a server-side implementation.

  2. useActionState: A new hook that manages form state in combination with server actions.

  3. useOptimistic: Enables optimistic UI updates while a form is submitting. React handles optimistic state and cleans it up automatically after the server action resolves.

  4. useFormStatus: Provides access to form submission states like pending and error, scoped to the nearest useActionState boundary.

  5. useTransition (now async-ready): Supports async functions—including server actions—making it easier to transition UI states smoothly during background updates.

To better understand the benefits, let’s compare them with the previous setups.

  1. React with local state
  2. React-hook-form
  3. React 19 with useActionState and useOptimistic.
1. React form with local state
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";

type FormState = {
    title: string;
    content: string;
    error?: string;
};

export default function NormalForm() {
    const [formData, setFormData] = useState<FormState>({
        title: "",
        content: "",
    });

    const [optimistic, setOptimistic] = useState<FormState>({
        title: "",
        content: "",
    });

    const [isSubmitting, setIsSubmitting] = useState(false);

    async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
        e.preventDefault();
        setIsSubmitting(true);
        setOptimistic({ ...formData });
        setFormData((prev) => ({ ...prev, error: undefined }));
        try {
            await createPostAction(formData);
        } catch {
            setFormData((prev) => ({
                ...prev,
                error: "Failed to create post",
            }));
        } finally {
            setIsSubmitting(false);
        }
    }

    return (
        <div className="max-w-md mx-auto p-4 space-y-4">
            {formData.error && <p className="text-red-500 font-semibold">{formData.error}</p>}

            <form onSubmit={handleSubmit} className="space-y-4">
                <Input
                    name="title"
                    value={formData.title}
                    onChange={(e) => setFormData((f) => ({ ...f, title: e.target.value }))}
                    placeholder="Title"
                />

                <Input
                    name="content"
                    value={formData.content}
                    onChange={(e) => setFormData((f) => ({ ...f, content: e.target.value }))}
                    placeholder="Content"
                />

                <Button type="submit" disabled={isSubmitting} className="w-full">
                    {isSubmitting ? "Submitting..." : "Submit"}
                </Button>
            </form>

            <div className="mt-6 space-y-1 text-sm text-muted-foreground">
                <p>
                    <strong>Optimistic Title:</strong> {optimistic.title}
                </p>
                <p>
                    <strong>Optimistic Content:</strong> {optimistic.content}
                </p>
            </div>
        </div>
    );
}

async function createPostAction(data: FormState): Promise<void> {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (data.title === "error" || data.content === "error") {
                reject(new Error("Fake post error"));
            } else {
                resolve();
            }
        }, 1000);
    });
}
2. React Hook form
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
    Form,
    FormControl,
    FormField,
    FormItem,
    FormLabel,
    FormMessage,
} from "@/components/ui/form";

const formSchema = z.object({
    title: z
        .string()
        .min(1, "Title is required")
        .refine((val) => val !== "error", {
            message: `Invalid value`,
        }),
    content: z
        .string()
        .min(1, "Content is required")
        .refine((val) => val !== "error", {
            message: `Invalid value`,
        }),
});

type FormValues = z.infer<typeof formSchema>;

export default function RHFForm() {
    const form = useForm<FormValues>({
        resolver: zodResolver(formSchema),
        defaultValues: {
            title: "",
            content: "",
        },
    });

    const [optimistic, setOptimistic] = useState<FormValues>({
        title: "",
        content: "",
    });

    const [isSubmitting, setIsSubmitting] = useState(false);
    const [error, setError] = useState<string | null>(null);

    async function onSubmit(data: FormValues) {
        setIsSubmitting(true);
        setOptimistic(data);
        setError(null);

        try {
            await createPostAction(data);
            // form.reset();
        } catch (err: unknown) {
            console.log(err);
            setError("Failed to create post");
        } finally {
            setIsSubmitting(false);
        }
    }

    return (
        <div className="max-w-md mx-auto p-4 space-y-4">
            {error && <p className="text-red-500 font-semibold">{error}</p>}

            <Form {...form}>
                <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
                    <FormField
                        control={form.control}
                        name="title"
                        render={({ field }) => (
                            <FormItem>
                                <FormLabel>Title</FormLabel>
                                <FormControl>
                                    <Input placeholder="Title" disabled={isSubmitting} {...field} />
                                </FormControl>
                                <FormMessage />
                            </FormItem>
                        )}
                    />

                    <FormField
                        control={form.control}
                        name="content"
                        render={({ field }) => (
                            <FormItem>
                                <FormLabel>Content</FormLabel>
                                <FormControl>
                                    <Input
                                        placeholder="Content"
                                        disabled={isSubmitting}
                                        {...field}
                                    />
                                </FormControl>
                                <FormMessage />
                            </FormItem>
                        )}
                    />
                    <Button type="submit" disabled={isSubmitting} className="w-full">
                        {isSubmitting ? "Submitting..." : "Submit"}
                    </Button>
                </form>
            </Form>

            <div className="mt-6 space-y-1 text-sm text-muted-foreground">
                <p>
                    <strong>Optimistic Title:</strong> {optimistic.title}
                </p>
                <p>
                    <strong>Optimistic Content:</strong> {optimistic.content}
                </p>
            </div>
        </div>
    );
}

async function createPostAction(data: FormValues): Promise<void> {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (data.title === "error" || data.content === "error") {
                reject(new Error("Fake post error"));
            } else {
                resolve();
            }
        }, 1000);
    });
}
3. React 19 with `useActionState` and `useOptimistic`
import { useActionState, useOptimistic } from "react";
import { createPostAction } from "./action";
import { useFormStatus } from "react-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

export type FormState = {
    title: string;
    content: string;
    error?: string;
};

export function FormWithActionState() {
    const [optimisticData, setOptimisticData] = useOptimistic<FormState>({
        title: "",
        content: "",
    });

    const handleOptimisticUpdate = (previousState: FormState, formData: FormData) => {
        const title = formData.get("title") as string;
        const content = formData.get("content") as string;
        setOptimisticData({ title, content });

        return createPostAction(previousState, formData);
    };

    const [formState, formAction, isSubmitting] = useActionState<FormState, FormData>(
        handleOptimisticUpdate,
        {
            title: "",
            content: "",
        },
    );

    if (formState.error) {
        return <h1 className="text-red-600">Error: {formState.error}</h1>;
    }

    return (
        <div className="p-4">
            {isSubmitting && <p>Submitting form...</p>}
            <form action={formAction} className="space-y-3">
                <TitleInput defaultValue={formState.title} />
                <ContentInput defaultValue={formState.content} />
                <Button type="submit" className="bg-black text-white px-4 py-2 rounded">
                    Submit
                </Button>
            </form>

            <div className="mt-6 text-sm text-neutral-600 dark:text-neutral-300">
                <p>
                    <strong>Optimistic Title:</strong> {optimisticData.title}
                </p>
                <p>
                    <strong>Optimistic Content:</strong> {optimisticData.content}
                </p>
            </div>
        </div>
    );
}

function TitleInput({ defaultValue }: { defaultValue: string }) {
    const { pending } = useFormStatus();
    if (pending) return <p>Loading title field...</p>;

    return (
        <Input
            type="text"
            name="title"
            defaultValue={defaultValue}
            className="w-full border border-gray-300 px-2 py-1 rounded"
            placeholder="Enter title"
            disabled={pending}
        />
    );
}

function ContentInput({ defaultValue }: { defaultValue: string }) {
    const { pending } = useFormStatus();

    if (pending) return <p>Loading content field...</p>;

    return (
        <Input
            type="text"
            name="content"
            defaultValue={defaultValue}
            className="w-full border border-gray-300 px-2 py-1 rounded"
            placeholder="Enter content"
            disabled={pending}
        />
    );
}
useActionState with Server Actions
"use server";
import { FormState } from "./ServerAction";

export async function createPostAction(
    prevState: FormState,
    payload: FormData,
): Promise<FormState> {
    const title = payload.get("title") as string;
    const content = payload.get("content") as string;

    if (title === "error" || content === "error") {
        return { ...prevState, error: "Failed to create post" };
    }

    return { title, content };
}

Problems with server actions

  1. No Middleware: Server actions are functions that are executed on the server but there is no middleware to handle the request and response.
  2. Fragmented: Each server action is deployed as a separate function. which means even a small form actions will now have a separate function.
  3. Tied to Frameworks: Although server actions are part of React 19, their actual usage (like form submission wiring, streaming, deployment) heavily depends on frameworks like Next.js. This makes them less portable or reusable outside these environments.

Comparison

CategoryReact (useState)React Hook FormReact 19 (Action State + Server Actions)
Access to intermediate stateYes (useState)Yes (watch)No (no access before submit)
Performance (re-renders)No (re-renders on every keystroke)Yes (controlled via context)Yes (minimal re-renders; form is server-driven)
Optimistic UpdatesNo (fully manual)No (manual if needed)Yes (useOptimistic)
BoilerplateNo (lots of useState, handlers)Yes (clean API, less state mgmt)Yes (useActionState is minimal)
ValidationManual or external libraryYes (built-in + schema support)No (manual in server action)
Form state access (pending, error)YesYesYes (useFormStatus, error from action return)

Improvements to use hook

React introduced the new use hook, which allows us to resolve promises in a client component while suspending rendering and showing a fallback. The use hook should be used in client components and takes a promise as a prop. In server components, we can directly use await, but the major caveat of using top-level await is that it blocks rendering on the server until the promise resolves.

  1. The use hook must be used inside a component and can be called conditionally or inside loops.
  2. The use hook can be used to read data from context, but there’s a caveat: when using use to access context inside a conditional statement, React may not correctly resolve the nearest parent provider.
  3. The use hook should resolve promises created in server components. Promises created inside client components can be unstable due to re-renders.

In this example we see how we can use use hook to resolve promises in a client component while suspending rendering and showing a fallback. In next.js all components are server components, so we can directly use await to resolve promises.

const simulateDelay = (ms: number, data: string, returnError: boolean) =>
  new Promise((resolve, reject) =>
    setTimeout(() => {
      if (returnError) {
        reject("Error");
      } else {
        resolve(data);
      }
    }, ms)
  )

export default async function Page() {
  const data = await simulateDelay(1000, new Date().getSeconds().toString(),false) as string;
  return (
    <div>
      Page
      {data}
    </div>
  );
}

This will load the initial page and then after 1 second it will show the data. We can improve this by using use hook to resolve the promise.



export default async function Page() {
  const data = simulateDelay(
    1000,
    new Date().getSeconds().toString(),
    true
  ) as unknown as Promise<string>;
  return (
    <div>
      Page
      <ErrorBoundary fallback={<div>Error</div>}>
        <Suspense fallback={<div>Loading...</div>}>
          <ClientComponent prom={data} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

// ClientComponent.tsx
"use client";

import { use } from "react";

export default function ClientComponent({ p: p }: { p: Promise<string> }) {
  const data = use(p) as string;
  return (
    <>
      <div>Client Component {data}</div>;
    </>
  );
}

This will allow rendering the other parts of the page and show a fallback while the promise is resolved.

Conclusion

React 19 introduces powerful primitives for handling forms, async data, and server actions more naturally. While it’s a big step forward, form libraries still offer value for complex needs like validation and schemas.

References:

React 19 blog

React server functions

React use hook

Next.js server actions

Lakshmanshankar © 2025