Who Owns Pending State? React 19 Actions and Forms in Next.js

Forms expose a common ownership split: the client owns interaction state, but the server owns the mutation. The difficult part is deciding which layer should own pending state, validation, returned errors, and reconciliation.

Choose the pending-state owner

I use four practical starting points:

InteractionPending-state ownerWhy
An imperative event handler calls a one-off server actionuseTransitionReact tracks the async action without another mutation abstraction.
A native form submits to a server actionuseActionStateThe form action owns submission, returned state, and pending state.
A form action delegates to a client mutationThe mutation’s isPendingThe mutation library owns the async lifecycle and cache work.
A form needs rich client validationTanStack Form for fields, useActionState for submissionEach tool owns the part it models best, while the server remains authoritative.

The examples below are larger than toy snippets, but each one follows that same ownership decision.

Server actions with startTransition

I usually split forms into two groups:

  • Simple forms, where the server action owns the mutation and validation can stay lightweight.
  • Complex forms, where the client needs rich validation, field state, and progressive feedback.

For simple cases, I call a server action from a client component and wrap the call in startTransition. That gives the UI an isPending state without introducing a mutation library for a one-off action.

Server actions with startTransition

export function CookieConsentDialog() {
  const [isPending, startTransition] = useTransition();
  const currentUrl = useCurrentUrl();

  function handleAcceptAllCookies() {
    startTransition(async () => {
      await updateCookieConsent({
        consent: { type: "all" },
        redirectUrl: currentUrl,
      });
    });
  }

  return (
    <Button isPending={isPending} onClick={handleAcceptAllCookies}>
      Accept
    </Button>
  );
}

The server action looks like this:

"use server";

import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import type { CookieConsent } from "@/components/cookie-banner/cookieConsentSchema";
import { cookieNames, cookieOptions } from "@/server/workos/cookie";

export async function updateCookieConsent({
  consent,
  redirectUrl,
}: {
  consent: CookieConsent;
  redirectUrl: string;
}) {
  const cookiesStore = await cookies();
  cookiesStore.set(
    cookieNames.cookieConsent,
    JSON.stringify(consent),
    cookieOptions,
  );
  revalidatePath(redirectUrl);
}

The server action owns the cookie mutation and revalidates the current path. The client component owns only the interaction state. For a larger workflow I might use a TanStack Query mutation, but for a small server action this keeps the boundary clean.

Plain Next.js forms

For a simpler login form, I do not need TanStack Form. Native form submission plus useActionState is enough: the server action receives FormData, validates it, returns a structured error when needed, and redirects on success.

Plain Next.js form using useActionState and a server action

import { useActionState, useId } from "react";
import Form from "next/form";
import { useQueryState } from "nuqs";
import { EmailVerificationForm } from "@/components/auth/EmailVerificationForm";
import { WorkOSErrorMessage } from "@/components/auth/WorkOSErrorMessage";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Label } from "@/components/ui/Label";
import { PasswordInput } from "@/components/ui/PasswordInput";
import type { SearchParamsKeys } from "@/lib/searchParams/SearchParamsKeys";
import { searchParamsParserConfig } from "@/lib/searchParams/searchParamsParserConfig";
import { signIn } from "@/server/actions/signIn";
import { useCurrentUrl } from "@/utils/url/useCurrentUrl";

export function LoginForm(props: { onForgetPassword: () => void }) {
  const { onForgetPassword } = props;

  const emailId = useId();
  const passwordId = useId();

  const [authRedirectUrl] = useQueryState(
    "authRedirectUrl" satisfies SearchParamsKeys,
    searchParamsParserConfig.authRedirectUrl,
  );

  const currentUrl = useCurrentUrl();

  const [signInState, signInAction, isPending] = useActionState(
    signIn.bind(null, { redirectUrl: authRedirectUrl ?? currentUrl }),
    null,
  );

  if (
    signInState?.code === "email_verification_required" &&
    signInState.pendingAuthenticationToken != null
  ) {
    return (
      <EmailVerificationForm
        isPaywall={false}
        pendingAuthenticationToken={signInState.pendingAuthenticationToken}
        email={signInState.email}
      />
    );
  }

  return (
    <Form action={signInAction} className="flex flex-col gap-4">
      <div className="grid grid-cols-[max-content_minmax(theme(space.48),1fr)] items-baseline gap-4">
        <Label htmlFor={emailId}>Email</Label>
        <Input
          required
          type="email"
          autoCapitalize="off"
          autoComplete="username"
          name="email"
          id={emailId}
          placeholder="Your email"
        />
        <Label htmlFor={passwordId}>Password</Label>
        <PasswordInput
          required
          name="password"
          id={passwordId}
          placeholder="Your password"
          autoCapitalize="off"
          autoComplete="current-password"
        />
        <div className="col-span-full flex justify-end">
          <Button
            variant="link"
            size="bare"
            onClick={() => {
              onForgetPassword();
            }}
          >
            Forgot your password?
          </Button>
        </div>
      </div>
      {signInState == null ? null : <WorkOSErrorMessage error={signInState} />}
      <div className="flex justify-center pt-2">
        <Button isPending={isPending} type="submit" size="44">
          Log in
        </Button>
      </div>
    </Form>
  );
}
"use server";

import "server-only";
import { redirect } from "next/navigation";
import * as z from "zod";
import { env } from "@/env";
import { handleWorkOSError } from "@/server/workos/handleWorkOSError";
import { setWorkOSAuthCookie } from "@/server/workos/setWorkOSAuthCookie";
import { workos } from "@/server/workos/workos";

const formDataSchema = z.object({
  email: z.email(),
  password: z.string(),
});

export async function signIn(
  { redirectUrl }: { redirectUrl: string },
  _prevState: unknown,
  formData: FormData,
) {
  try {
    const { email, password } = formDataSchema.parse({
      email: formData.get("email"),
      password: formData.get("password"),
    });

    const user = await workos.userManagement.authenticateWithPassword({
      clientId: env.WORKOS_CLIENT_ID,
      email,
      password,
    });

    await setWorkOSAuthCookie(user);
  } catch (error) {
    return handleWorkOSError(error);
  }
  redirect(redirectUrl);
}

Form actions with TanStack Query mutations

A bookmark button can submit a form action while TanStack Query owns the mutation lifecycle:

function BookmarkButton(props: {
  isBookmarked: boolean;
  resourceId: string;
  resourceType: "context_card" | "article";
}) {
  const { resourceType, resourceId, isBookmarked } = props;
  const updateBookmarkMutation = useUpdateBookmarkMutation();

  function updateBookmark() {
    updateBookmarkMutation.mutate({
      resourceType,
      resourceId,
      isBookmarked: !isBookmarked,
    });
  }

  return (
    <Form action={updateBookmark}>
      <IconButton
        type="submit"
        disabled={updateBookmarkMutation.isPending}
        isActive={isBookmarked}
        iconName={isBookmarked ? "bookmark-fill" : "bookmark"}
        title={isBookmarked ? "bookmarked" : "bookmark"}
      />
    </Form>
  );
}

Here, updateBookmark still runs as a form Action. But because mutate() starts TanStack Query’s async work and returns immediately, React’s form Action is only responsible for the submit boundary. The pending state should still come from updateBookmarkMutation.isPending. If I wanted React’s form pending state to cover the mutation itself, I would use mutateAsync() and await it inside the action.

TanStack Form with server actions

When the form needs richer client-side validation, I still want the server action to be the final authority. TanStack Form can own the field state and validation experience, while useActionState connects the form submission to a server action and exposes the pending state and server result.

TanStack Form coordinating client validation with a server action

The client component is doing two jobs at the same time. TanStack Form owns the interactive form model: field values, touched state, validation state, and whether the form can currently be submitted. The Next.js form action still owns the actual submit path. That is why the inputs are controlled by field.state.value and field.handleChange, but they also keep normal name attributes. On submit, the browser still produces FormData for the server action.

useActionState is the bridge between those two worlds. Binding redirectUrl creates a server action with the shape React expects for form submissions, and the returned createAccountAction is passed to <Form action={createAccountAction}>. The third value, isPending, drives the loading state while the server action is running.

Notice that this example does not call startTransition directly. The action is not being invoked from a normal click handler; it is passed to the form’s action prop. React treats that submit path as an Action and runs it in the right transition context, which is why useActionState can track isPending for the submission. I only reach for an explicit startTransition when I call an action or mutation myself from an event handler, like the cookie-consent button above.

"use client";

import { useActionState, useId } from "react";
import { useForm } from "@tanstack/react-form";
import { clsx } from "clsx";
import Form from "next/form";
import { useQueryState } from "nuqs";
import { AcceptTermsOfServiceAndPrivacyPolicy } from "@/components/auth/AcceptTermsOfServiceAndPrivacyPolicy";
import { EmailVerificationForm } from "@/components/auth/EmailVerificationForm";
import { WorkOSErrorMessage } from "@/components/auth/WorkOSErrorMessage";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Label } from "@/components/ui/Label";
import { PasswordInput } from "@/components/ui/PasswordInput";
import { Icon } from "@/components/ui/icons/Icon";
import type { SearchParamsKeys } from "@/lib/searchParams/SearchParamsKeys";
import { searchParamsParserConfig } from "@/lib/searchParams/searchParamsParserConfig";
import { createAccount } from "@/server/actions/createAccount";
import { useCurrentUrl } from "@/utils/url/useCurrentUrl";
import {
  createAccountFormOptions,
  createAccountFormSchema,
} from "./schemas/createAccountFormSchemas";
import {
  passwordRequirementCodesToMessage,
  passwordValidator,
} from "./schemas/passwordSchema";

export function CreateAccountForm() {
  const emailId = useId();
  const passwordId = useId();
  const firstNameId = useId();
  const lastNameId = useId();

  const [authRedirectUrl] = useQueryState(
    "authRedirectUrl" satisfies SearchParamsKeys,
    searchParamsParserConfig.authRedirectUrl,
  );
  const currentUrl = useCurrentUrl();

  const [createAccountState, createAccountAction, isPending] = useActionState(
    createAccount.bind(null, { redirectUrl: authRedirectUrl ?? currentUrl }),
    null,
  );

  const form = useForm({
    ...createAccountFormOptions,
    validators: {
      onMount: createAccountFormSchema,
      onChange: createAccountFormSchema,
    },
  });

  if (
    createAccountState?.code === "email_verification_required" &&
    createAccountState.pendingAuthenticationToken != null
  ) {
    return (
      <EmailVerificationForm
        isPaywall={false}
        email={createAccountState.email}
        pendingAuthenticationToken={
          createAccountState.pendingAuthenticationToken
        }
      />
    );
  }

  return (
    <Form
      className="grid grid-cols-[max-content_minmax(theme(space.48),1fr)] items-baseline gap-4"
      action={createAccountAction}
    >
      <form.Field name="firstName">
        {(field) => {
          return (
            <>
              <Label htmlFor={firstNameId}>First Name</Label>
              <Input
                required
                name="firstName"
                id={firstNameId}
                value={field.state.value}
                onBlur={field.handleBlur}
                onChange={(e) => {
                  field.handleChange(e.target.value);
                }}
              />
            </>
          );
        }}
      </form.Field>

      <form.Field name="lastName">
        {(field) => {
          return (
            <>
              <Label htmlFor={lastNameId}>Last Name</Label>
              <Input
                required
                name="lastName"
                id={lastNameId}
                value={field.state.value}
                onBlur={field.handleBlur}
                onChange={(e) => {
                  field.handleChange(e.target.value);
                }}
              />
            </>
          );
        }}
      </form.Field>

      <form.Field name="email">
        {(field) => {
          return (
            <>
              <Label htmlFor={emailId}>Email</Label>
              <Input
                required
                type="email"
                autoCapitalize="off"
                autoComplete="email"
                inputMode="email"
                name="email"
                id={emailId}
                placeholder="Your email"
                value={field.state.value}
                onBlur={field.handleBlur}
                onChange={(e) => {
                  field.handleChange(e.target.value);
                }}
              />
            </>
          );
        }}
      </form.Field>
      <form.Field
        name="password"
        validators={{
          onChange: passwordValidator,
        }}
      >
        {(field) => {
          return (
            <>
              <Label htmlFor={passwordId}>Password</Label>
              <PasswordInput
                required
                name="password"
                id={passwordId}
                placeholder="Your password"
                autoCapitalize="off"
                autoComplete="new-password"
                value={field.state.value}
                onBlur={field.handleBlur}
                onChange={(e) => {
                  field.handleChange(e.target.value);
                }}
              />
              <ul className="col-2 flex flex-col gap-1">
                {Object.entries(passwordRequirementCodesToMessage).map(
                  ([code, message]) => {
                    const isValid =
                      field.state.meta.isDirty &&
                      !field.state.meta.errors.includes(code);
                    return (
                      <li key={code} className="flex items-center gap-3">
                        <Icon
                          name={
                            isValid
                              ? "check-circle-completed"
                              : "check-circle-incompleted"
                          }
                          className={clsx(
                            "size-4",
                            isValid ? "text-brand-primary" : "text-background2",
                          )}
                        />
                        <div className="text-sm">{message}</div>
                      </li>
                    );
                  },
                )}
              </ul>
            </>
          );
        }}
      </form.Field>

      <div className="col-span-full flex flex-col items-center gap-8 pt-4">
        {createAccountState == null ? null : (
          <WorkOSErrorMessage error={createAccountState} />
        )}
        <div className="pt-1">
          <form.Subscribe selector={(state) => state.canSubmit}>
            {(canSubmit) => {
              return (
                <Button
                  disabled={!canSubmit}
                  isPending={isPending}
                  size="44"
                  type="submit"
                >
                  Join now
                </Button>
              );
            }}
          </form.Subscribe>
        </div>
        <div className="max-w-80">
          <AcceptTermsOfServiceAndPrivacyPolicy />
        </div>
      </div>
    </Form>
  );
}

The shared schema is the important part of the client setup. useForm uses createAccountFormSchema on mount and on change, so the submit button can stay disabled until the form is valid. The password field adds a narrower field-level validator so the UI can show each password requirement as the user types.

The server action then repeats the same validation before touching WorkOS:

"use server";

import "server-only";
import {
  ServerValidateError,
  createServerValidate,
} from "@tanstack/react-form/nextjs";
import { redirect } from "next/navigation";
import {
  createAccountFormOptions,
  createAccountFormSchema,
} from "@/components/auth/schemas/createAccountFormSchemas";
import { env } from "@/env";
import { handleWorkOSError } from "@/server/workos/handleWorkOSError";
import { setWorkOSAuthCookie } from "@/server/workos/setWorkOSAuthCookie";
import { workos } from "@/server/workos/workos";

const serverValidate = createServerValidate({
  ...createAccountFormOptions,
  onServerValidate: createAccountFormSchema,
});

export async function createAccount(
  { redirectUrl }: { redirectUrl: string },
  _prevState: unknown,
  formData: FormData,
) {
  try {
    const { email, password, firstName, lastName } =
      await serverValidate(formData);

    await workos.userManagement.createUser({
      firstName,
      lastName,
      email,
      password,
    });

    // Sign in after create the account is required
    const signInUser = await workos.userManagement.authenticateWithPassword({
      email,
      password,
      clientId: env.WORKOS_CLIENT_ID,
    });

    await setWorkOSAuthCookie(signInUser);
  } catch (error) {
    if (error instanceof ServerValidateError) {
      return {
        code: "form_schema_error" as const,
        errors: error.formState.errors,
      };
    }

    return handleWorkOSError(error);
  }

  redirect(redirectUrl);
}

On the server, createServerValidate turns the same form options and schema into a FormData validator. Because redirectUrl was bound on the client, the action receives three arguments: the bound redirect options, React’s previous action state, and the submitted FormData.

After serverValidate(formData) succeeds, the action can safely create the user, authenticate them, set the auth cookie, and redirect. If TanStack Form’s server validation fails, the action returns a structured form_schema_error. If WorkOS rejects the request, handleWorkOSError turns that into the same kind of action state the client already knows how to render.

That is the boundary I want here: TanStack Form makes the form pleasant before submit, useActionState carries the pending state and returned errors, and the server action remains the final authority before any account is created.

The ownership boundary

Pending state should come from the layer that actually owns the asynchronous work. An imperative server action belongs to React’s transition. A form submission belongs to useActionState. A TanStack Query mutation belongs to the mutation object. Rich field state belongs to TanStack Form, even when the final mutation still belongs to the server.

The hooks matter less than making that ownership explicit. For the read-side boundaries—URL state, browser APIs, external caches, RSC snapshots, and optimistic state—continue with Advanced React State Boundaries in a Real Next.js App.