React 19 Patterns skill for Codex

Let Actions own pending, errors and reset. Codex rewrites mutations as React 19 Actions instead of juggling isSubmitting flags, places useFormStatus where it actually works, keeps use() away from render-created promises, and converts forwardRef to plain ref props. It checks the installed React version first and includes an upgrade path from 18 with codemods and the type errors to expect.

  • Frontend
  • $react-19-patterns
  • 216 lines
  • 3 files
  • ~2,400 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- react-19-patterns

Installs to ~/.agents/skills/react-19-patterns/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $react-19-patterns, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes and refactors React 19 code with Actions, useActionState, useFormStatus, useOptimistic, use() for promises and context, ref as a prop, ref cleanup, and form actions, replacing hand-rolled loading flags and forwardRef. Use when building forms or mutations in React 19, removing forwardRef, reading a promise with use(), adding optimistic UI, upgrading from React 18, or when a pending state, form reset or ref callback behaves unexpectedly.

Use it when

  • Building forms or mutations in a React 19 codebase
  • Replacing manual loading flags with transitions and Actions
  • Reading promises or context with use() under Suspense
  • Upgrading an app from React 18 to 19

Use something else when

How to check the result

Type-check and lint pass, and a UI test submits the form, sees the pending label, then the success or validation message; with throttled network, optimistic items revert on failure.

The mistake it prevents

Converting a (formData) handler to useActionState without adding the previousState first parameter, so the action reads state where it expects FormData.

~/.agents/skills/react-19-patterns/SKILL.md

--- name: react-19-patterns description: Writes and refactors React 19 code with Actions, useActionState, useFormStatus, useOptimistic, use() for promises and context, ref as a prop, ref… license: MIT ---

Done well means: mutations run as Actions so React owns pending, error and reset state; no component keeps a manual isSubmitting flag next to a transition; refs flow as plain props; and every pattern used exists in the React version the lockfile actually pins.

Before writing code

  1. Read AGENTS.md for the package manager, lint and test commands.
  2. Check the real versions: npm ls react react-dom @types/react (or the pnpm/yarn equivalent). useActionState, use, ref-as-prop and form actions need 19.0+; useEffectEvent and <Activity> need 19.2+.
  3. Identify the framework. In Next.js App Router, React Router v7 or TanStack Start, a form action can be a server function; in a Vite SPA it is a client async function. The hooks are the same, the transport is not.
  4. Find existing mutation code (grep -rn "isLoading\|isSubmitting\|forwardRef" src) so the refactor follows one consistent pattern.

Decision table

NeedUseNot
Form submit with server result and errorsuseActionState + <form action>useState + onSubmit + e.preventDefault()
Submit button disabled while its form runsuseFormStatus in a child componentprop-drilling pending
Non-form async update with pending UIuseTransition + async functionmanual loading boolean
Show the change before the server confirmsuseOptimistic inside an Actioncopying server state into local state
Read a promise created by a parent or serveruse(promise) under <Suspense>useEffect + useState fetch
Read context conditionallyuse(Context)early-return before useContext
Expose a DOM node to a parentref as a normal propforwardRef
Event handler that reads latest props in an effect (19.2+)useEffectEventadding the value to deps and re-subscribing
Data fetching with caching, retries, dedupea data library (TanStack Query, router loaders)use() with a promise created during render

Actions and form state

An Action is any async function run inside a transition. React tracks pending state, surfaces thrown errors to the nearest error boundary, and, for <form action>, resets uncontrolled fields after success.

import { useActionState } from "react";
import { useFormStatus } from "react-dom";

type State = { ok: boolean; errors?: Record<string, string>; message?: string };

async function saveProfile(prev: State, formData: FormData): Promise<State> {
  const name = String(formData.get("name") ?? "").trim();
  if (!name) return { ok: false, errors: { name: "Name is required" } };
  const res = await fetch("/api/profile", { method: "POST", body: formData });
  if (!res.ok) return { ok: false, message: "Could not save. Try again." };
  return { ok: true, message: "Saved" };
}

function SubmitButton() {
  const { pending } = useFormStatus(); // reads the parent <form>, so it must be a child
  return <button disabled={pending}>{pending ? "Saving..." : "Save"}</button>;
}

export function ProfileForm({ defaultName }: { defaultName: string }) {
  const [state, formAction, isPending] = useActionState(saveProfile, { ok: false });
  return (
    <form action={formAction} aria-busy={isPending}>
      <input name="name" defaultValue={defaultName} aria-invalid={!!state.errors?.name} />
      {state.errors?.name && <p role="alert">{state.errors.name}</p>}
      <SubmitButton />
      {state.message && <p aria-live="polite">{state.message}</p>}
    </form>
  );
}

Rules:

  • Return expected failures (validation, 4xx) as state. Throw only for unexpected failures you want an error boundary to catch.
  • The action receives (previousState, formData). Forgetting the first parameter is the most common bug when converting an existing (formData) handler.
  • Uncontrolled inputs are reset after a successful form action. If the field must keep its value on validation error, return the submitted values in state and feed them back through defaultValue, or give the form a key that changes only on success.
  • useFormStatus returns nothing useful when called in the component that renders the <form>; it must live in a descendant.
  • Use requestFormReset(form) from react-dom when you need to reset a form manually inside a transition.

Transitions outside forms

const [isPending, startTransition] = useTransition();

function onArchive(id: string) {
  startTransition(async () => {
    const result = await archive(id);
    if (result.error) {
      setError(result.error);
      return;
    }
    // React does not yet keep state updates that run after an await inside the
    // transition; wrap them again if they should not trigger a Suspense fallback.
    startTransition(() => setSelected(null));
  });
}

Optimistic updates

const [optimisticTodos, addOptimistic] = useOptimistic(
  todos,
  (current, newTodo: Todo) => [...current, { ...newTodo, pending: true }],
);

async function createAction(formData: FormData) {
  const todo = { id: crypto.randomUUID(), title: String(formData.get("title")) };
  addOptimistic(todo);   // must be called inside an Action or transition
  await createTodo(todo); // when the Action settles, React drops the optimistic value
}
  • The optimistic value reverts automatically when the Action ends; the real todos must update from the server response (revalidation, refetch, or setState) or the item disappears.
  • Calling addOptimistic outside a transition logs a warning and does nothing useful.
  • Render pending items differently (opacity, “Sending…”) so users can tell.

use()

// Parent (server component or route loader) starts the request; child suspends on it.
function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  const comments = use(commentsPromise);
  return comments.map((c) => <p key={c.id}>{c.body}</p>);
}

<Suspense fallback={<Spinner />}>
  <Comments commentsPromise={commentsPromise} />
</Suspense>;
  • Never create the promise during render in a client component (use(fetch(url))): each render makes a new promise and the component suspends forever. The promise must be stable: created by a server component, a loader, or cached.
  • A rejected promise goes to the nearest error boundary. Use .catch() on the promise to provide a fallback value instead.
  • use can be called inside if and loops, unlike other hooks, but not in try/catch.

Refs

function TextField({ ref, label, ...props }: { ref?: React.Ref<HTMLInputElement>; label: string } & React.ComponentProps<"input">) {
  return (
    <label>
      {label}
      <input ref={ref} {...props} />
    </label>
  );
}
  • Ref callbacks may return a cleanup function. Because of that, TypeScript rejects an implicit return: write ref={(el) => { instance = el; }}, not ref={(el) => (instance = el)}.
  • forwardRef still works but is no longer needed; convert it when touching the file, not in a repo-wide sweep without tests.
  • useRef() now requires an argument in the types: useRef<HTMLDivElement>(null).

Other React 19 changes worth using

  • <Context value={...}> renders a provider directly; <Context.Provider> still works.
  • <title>, <meta> and <link> rendered anywhere are hoisted to <head>. In Next.js prefer its Metadata API; in plain React or React Router this removes the need for helmet-style libraries.
  • preload, preinit and preconnect from react-dom hint resources from components.
  • useDeferredValue(value, initialValue) accepts an initial value for the first render.

Upgrading from React 18

For the full upgrade procedure, removed APIs and codemods, read references/react-18-upgrade.md only when the task is a version bump rather than writing new 19-style code. Codemods run through npx, which needs network access; in a sandbox without network, ask the user to approve it or apply the mechanical changes by hand.

Gotchas

  • Mixing onSubmit with e.preventDefault() and action on one form: the onSubmit wins and the Action never runs. Pick one.
  • Returning a new object from the action on every call is fine, but do not store derived UI state (open dialogs, tabs) in action state; it resets on navigation in frameworks that preserve forms.
  • isPending from useActionState is per hook instance. Two forms sharing one action need two hooks.
  • Strict Mode double-invokes ref callbacks (mount, cleanup, mount) in development. A ref callback that subscribes must return its cleanup.
  • Server functions passed to action must be imported from a "use server" module; an inline client function cannot run on the server.

Verification

  • tsc --noEmit passes (catches ref callback returns and useRef() without args).
  • Lint passes with eslint-plugin-react-hooks on its current recommended config.
  • Tests submit the form through the UI (Testing Library user.click on the submit button) and assert pending text, success message and validation errors.
  • With network throttled in DevTools, the pending state appears, double submits are blocked, and an optimistic item reverts when the request fails.
  • No leftover forwardRef or manual isSubmitting in files you converted.

Output

When reporting the change, list: which mutations became Actions, which components lost forwardRef, any behavior differences users will notice (form reset after success, optimistic rows), and the commands you ran to verify.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "React 19 Patterns"
  short_description: "Let Actions own pending, errors and reset"
  default_prompt: "$react-19-patterns Convert this profile form to useActionState and show field errors from the server"
references/react-18-upgrade.md75 lines, 3.1 KB
# Upgrading a codebase from React 18 to React 19

Read this only when the task is the version bump itself.

## Order of work

```
- [ ] 1. Upgrade to the latest 18.3.x first and fix every new console warning
- [ ] 2. Bump react, react-dom, @types/react, @types/react-dom together
- [ ] 3. Run the codemods (needs network for npx)
- [ ] 4. Fix type errors
- [ ] 5. Upgrade libraries that pin React 18 peer ranges
- [ ] 6. Run the full test suite and a manual smoke pass
```

React 18.3 exists only to print deprecation warnings for APIs removed in 19. Running the
app and tests on 18.3 first turns the upgrade into a list of warnings to clear.

## Install

```bash
npm install react@^19 react-dom@^19
npm install -D @types/react@^19 @types/react-dom@^19
```

Use the project's package manager from `AGENTS.md` or the lockfile. Installing packages
needs network access; in a sandbox without it, ask the user to approve or to run it.

## Codemods

```bash
npx codemod@latest react/19/migration-recipe        # runtime API changes
npx types-react-codemod@latest preset-19 ./src      # type-level changes
```

Review the diff file by file; codemods handle the common shapes, not every one.

## Removed or changed APIs

| React 18 | React 19 replacement |
|---|---|
| `ReactDOM.render`, `ReactDOM.hydrate` | `createRoot(el).render()`, `hydrateRoot(el, <App />)` |
| `unmountComponentAtNode` | `root.unmount()` |
| `ReactDOM.findDOMNode` | a ref on the DOM node |
| `propTypes` on function components | TypeScript or runtime validation at boundaries (silently ignored now) |
| `defaultProps` on function components | ES default parameters |
| String refs (`ref="input"`) | callback refs or `useRef` |
| Legacy context (`contextTypes`, `getChildContext`) | `createContext` |
| `react-test-renderer/shallow` | Testing Library; test renderer is deprecated |
| `act` from `react-dom/test-utils` | `act` from `react` |
| `element.ref` access | `element.props.ref` |

## Type changes that cause most errors

- `useRef()` with no argument is an error; pass `null` or an initial value.
- `ReactElement` props default to `unknown` instead of `any`.
- The global `JSX` namespace is gone; use `React.JSX`.
- Ref callbacks with an implicit return fail because returns are now cleanup functions.

## Behavior changes to test for

- Errors thrown during render are no longer re-thrown; they go to
  `window.reportError` (uncaught) or `console.error` (caught by a boundary). Use the
  `onUncaughtError` and `onCaughtError` options of `createRoot` to report them.
- Suspense no longer pre-renders siblings before committing a fallback, so fallbacks
  show sooner; waterfalls inside one Suspense boundary become more visible.
- `useFormState` from `react-dom` is renamed `useActionState` and moved to `react`;
  the new hook also returns `isPending`.

## Libraries

Run `npm ls react` and look for packages with `peerDependencies` on `react@^18` only.
Common ones that needed majors: state managers, form libraries, animation libraries,
testing utilities. Upgrade them in the same branch; do not use `--force` or
`--legacy-peer-deps` to hide a mismatch without checking the library supports 19.

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem