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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- react-19-patterns Installs to ~/.agents/skills/react-19-patterns/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install react-19-patterns The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project react-19-patterns Installs to .agents/skills/react-19-patterns/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/react-19-patterns.zipunzip -o react-19-patterns.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) react-19-patterns PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- The form needs field-level validation rules and complex schemas: use react-hook-form-zod
- The question is where server and client components split: use react-server-components
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.
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
- Read
AGENTS.mdfor the package manager, lint and test commands. - 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+;useEffectEventand<Activity>need 19.2+. - 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.
- Find existing mutation code (
grep -rn "isLoading\|isSubmitting\|forwardRef" src) so the refactor follows one consistent pattern.
Decision table
| Need | Use | Not |
|---|---|---|
| Form submit with server result and errors | useActionState + <form action> | useState + onSubmit + e.preventDefault() |
| Submit button disabled while its form runs | useFormStatus in a child component | prop-drilling pending |
| Non-form async update with pending UI | useTransition + async function | manual loading boolean |
| Show the change before the server confirms | useOptimistic inside an Action | copying server state into local state |
| Read a promise created by a parent or server | use(promise) under <Suspense> | useEffect + useState fetch |
| Read context conditionally | use(Context) | early-return before useContext |
| Expose a DOM node to a parent | ref as a normal prop | forwardRef |
| Event handler that reads latest props in an effect (19.2+) | useEffectEvent | adding the value to deps and re-subscribing |
| Data fetching with caching, retries, dedupe | a 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 akeythat changes only on success. useFormStatusreturns nothing useful when called in the component that renders the<form>; it must live in a descendant.- Use
requestFormReset(form)fromreact-domwhen 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
todosmust update from the server response (revalidation, refetch, or setState) or the item disappears. - Calling
addOptimisticoutside 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. usecan be called insideifand 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; }}, notref={(el) => (instance = el)}. forwardRefstill 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,preinitandpreconnectfromreact-domhint 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
onSubmitwithe.preventDefault()andactionon one form: theonSubmitwins 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.
isPendingfromuseActionStateis 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
actionmust be imported from a"use server"module; an inline client function cannot run on the server.
Verification
-
tsc --noEmitpasses (catches ref callback returns anduseRef()without args). - Lint passes with
eslint-plugin-react-hookson its current recommended config. - Tests submit the form through the UI (Testing Library
user.clickon 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
forwardRefor manualisSubmittingin 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.yaml
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.md
# 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.