105: React 19 and 19.2 — Actions, Optimistic UI, Form Status, and Activity
Learning objectives
By the end of this lesson, you should be able to:
- explain React Actions as asynchronous transitions;
- submit forms with function Actions;
- manage Action state with
useActionState; - read the status of a parent form with
useFormStatus; - represent temporary optimistic state with
useOptimistic; - use
<Activity>to preserve hidden UI state while changing its priority and visibility; - distinguish React Actions from API authorization and server-state caching;
- recognize when these APIs complement TanStack Query instead of replacing it.
Actions mental model
The easiest way to think about an Action is as asynchronous work performed within a transition. That context gives React information about the work it needs to coordinate rather than leaving every component to invent its own pending-state plumbing.
React can coordinate several related pieces of UI behavior:
- pending state;
- optimistic UI;
- form submission;
- error propagation;
- transition scheduling.
Here is a small client-side Action:
async function saveTask(formData) {
const title =
String(
formData.get('title') ?? '',
).trim();
if (title.length < 3) {
return;
}
await createTask({ title });
}
function TaskForm() {
return (
<form action={saveTask}>
<input
name="title"
required
minLength={3}
/>
<button>Save</button>
</form>
);
}
The significant detail is action={saveTask}. Passing a function to <form action> is not the same as supplying the traditional string URL action. React can invoke the function with the submitted FormData and coordinate the related transition.
That coordination does not make createTask trustworthy by itself. It only gives the UI a useful model for starting and tracking the work. Validation, authorization, and persistence still belong to the appropriate server-side boundaries.
The Action is also not a replacement for ordinary JavaScript error handling. Decide what the caller should see when the operation rejects, and decide where an unexpected failure should be logged. A pending indicator tells the user that work is underway; it does not tell you whether the work succeeded.
When debugging a form Action, inspect the component tree first, then inspect the submitted FormData and the network request. A button that appears stuck may reflect a rejected request, an Action that never reaches its awaitable work, or status UI that is outside the relevant form. Keep those failure locations distinct.
useActionState
Sometimes an Action needs to return more than a promise of completion. A form may need to display a validation message, retain a saved result, or distinguish success from an expected failure. useActionState provides state for that result and passes the previous state into the Action.
import {
useActionState,
} from 'react';
async function createTaskAction(
previousState,
formData,
) {
const title =
String(
formData.get('title') ?? '',
).trim();
if (title.length < 3) {
return {
status: 'invalid',
message:
'Enter at least 3 characters.',
};
}
try {
const task =
await createTask({ title });
return {
status: 'success',
task,
message: '',
};
} catch {
return {
status: 'error',
message:
'Could not create task.',
};
}
}
function TaskForm() {
const [
state,
formAction,
isPending,
] = useActionState(
createTaskAction,
{
status: 'idle',
task: null,
message: '',
},
);
return (
<form action={formAction}>
<label htmlFor="title">
Title
</label>
<input
id="title"
name="title"
/>
<button disabled={isPending}>
{isPending
? 'Saving…'
: 'Save task'}
</button>
{state.message && (
<p
role={
state.status === 'error' ||
state.status === 'invalid'
? 'alert'
: 'status'
}
>
{state.message}
</p>
)}
</form>
);
}
The returned array contains the current result state, a form-ready Action, and the pending flag. The Action receives the previous result first and the submitted FormData second. That ordering is easy to miss when converting an ordinary submit handler.
The initial state should describe every state the UI can render. In this example, idle, invalid, success, and error are explicit enough for the message and button behavior to remain understandable. As the form grows, keep the result shape consistent so a previous response does not leave fields that a later render interprets incorrectly.
useActionState is a client-side state-management mechanism; it is not server validation by itself. The server must still validate the submitted values and authorize the requested operation. A client can submit a request without using this Hook at all.
useFormStatus
Passing isPending through several component layers is often unnecessary. A submit button rendered inside the form can read the nearest parent form's submission status directly with useFormStatus.
import {
useFormStatus,
} from 'react-dom';
function SubmitButton() {
const {
pending,
data,
method,
action,
} = useFormStatus();
return (
<button disabled={pending}>
{pending
? 'Saving…'
: 'Save'}
</button>
);
}
The example destructures more status fields than it renders. pending drives the button here; data, method, and action are available when the component needs to inspect the current submission.
This avoids threading isPending through several component layers. There is one placement rule to remember: useFormStatus must run in a component rendered inside the form whose status it should observe. The component that renders the <form> is not a descendant of that form and therefore cannot read that form's status this way.
The nearest-form rule matters when forms are nested in reusable layouts or when a component is reused in more than one form. The status belongs to the form in the rendered ancestry, not to a form that happens to be nearby in the source file. If a status indicator reports the wrong submission, inspect that ancestry rather than adding another prop blindly.
useOptimistic
Optimistic UI shows the result the user expects before the authoritative operation has completed. This can make a short network round trip feel immediate, but the displayed value remains provisional until the server confirms it.
import {
useOptimistic,
} from 'react';
function TaskList({
tasks,
addTaskAction,
}) {
const [
optimisticTasks,
addOptimisticTask,
] = useOptimistic(
tasks,
(current, task) => [
...current,
{
...task,
optimistic: true,
},
],
);
async function action(formData) {
const title =
String(
formData.get('title') ?? '',
).trim();
const optimisticTask = {
id: `temp-${Date.now()}`,
title,
completed: false,
};
addOptimisticTask(
optimisticTask,
);
await addTaskAction({
title,
});
}
return (
<>
<form action={action}>
<input name="title" />
<button>Add</button>
</form>
<ul>
{optimisticTasks.map(
(task) => (
<li key={task.id}>
{task.title}
{task.optimistic
? ' (saving…)'
: ''}
</li>
),
)}
</ul>
</>
);
}
The temporary task gives the list something to render while addTaskAction is in flight. It is speculative data, not a successful database record. The server response remains authoritative. If the server assigns the real ID, normalizes the title, or applies other business rules, reconcile the optimistic item with that response rather than allowing the temporary representation to become permanent.
Optimistic rendering also needs a failure path. Decide what happens if addTaskAction rejects: remove the temporary row, mark it as failed, or leave it available for retry. That decision should be visible in the UI model; it should not be hidden in an unhandled promise rejection.
React Action versus TanStack Query mutation
React Actions and TanStack Query mutations address related problems, but they are not competing names for exactly the same abstraction.
React Actions coordinate UI transitions and form behavior. TanStack Query v5 coordinates server-state cache concerns such as:
- cache entries;
- invalidation;
- stale/fresh state;
- refetching;
- mutation state;
- optimistic cache updates;
- pagination.
A production application may submit through a form Action and then invalidate or update the relevant TanStack Query cache entries. The Action handles the interaction; the query library keeps other consumers of the server resource coherent.
Do not copy server data into unrelated local state merely because an Action returned it. Decide whether the value is transient form state or shared server state, and keep it in the system responsible for that job.
Server Functions
In a framework that supports React Server Components, a function marked with "use server" can be called from client code as a Server Function.
The directive has a narrow meaning that is often misunderstood:
"use server" does not mark a component as a Server Component.
There is no "use server" directive for declaring Server Components. It declares Server Functions. Server Components and Server Functions are covered in more depth in lesson 115.
<Activity> in React 19.2
<Activity> allows React to hide or show a subtree while preserving its state and managing the priority of its work. This is useful when a user switches away from a panel but may return shortly and should not lose a draft or the panel's internal UI state.
Here is a simplified conceptual example:
import {
Activity,
useState,
} from 'react';
function Dashboard() {
const [tab, setTab] =
useState('tasks');
return (
<>
<nav>
<button
onClick={() =>
setTab('tasks')
}
>
Tasks
</button>
<button
onClick={() =>
setTab('analytics')
}
>
Analytics
</button>
</nav>
<Activity
mode={
tab === 'tasks'
? 'visible'
: 'hidden'
}
>
<TaskWorkspace />
</Activity>
<Activity
mode={
tab === 'analytics'
? 'visible'
: 'hidden'
}
>
<Analytics />
</Activity>
</>
);
}
Use Activity when retaining the hidden subtree's state has clear value. Do not substitute it automatically for conditional rendering. Hidden UI still occupies memory, and preserving state can be undesirable for sensitive data, stale subscriptions, or screens that should reset on re-entry.
The choice is therefore about lifecycle semantics, not only animation or visual hiding. Conditional rendering says that the subtree no longer belongs in the current UI. Activity says that the subtree is temporarily not visible or not a priority, while its continuity may still matter. Choose the model that matches the product's expected return behavior.
Pending UX
Pending UI is not just a spinner. A useful pending state should:
- disable duplicate destructive submissions when that protection is needed;
- leave enough context visible for the user to understand what is being submitted;
- use
aria-busyor status text when appropriate; - avoid claiming success before the authoritative system confirms it;
- provide a retry path when the operation genuinely fails.
Replacing the entire form with a spinner is often counterproductive. If the user needs to inspect what they submitted, keep that context available while the request is pending.
Validation model
Use validation in three layers, with different responsibilities:
- native/client constraints for immediate guidance;
- schema or client-side form validation where it improves the interaction;
- server validation and authorization as the authority.
For example, an expected validation problem can be returned as form state:
Title is required
That lets the UI explain what the user should fix. It does not replace the server's own check.
An authorization failure is a different category:
You may not edit this task
The server must enforce that decision. A message rendered by the client is not a security boundary, and a disabled button does not prevent a crafted request.
Common mistakes
Calling optimistic setter outside an Action
useOptimistic is designed around Action and transition work. Calling its setter outside the intended workflow can produce behavior that does not match the pending operation the UI is trying to represent.
Using Actions as a cache
Actions do not provide query freshness or invalidation. If several parts of the application observe the same server resource, use the server-state mechanism responsible for keeping those consumers synchronized.
Treating pending as success
Pending means that work has started and has not yet reached an authoritative result. Do not permanently display a server-generated task ID, or any other confirmed value, before the server returns it.
Confusing Server Components with "use server"
"use server" is for Server Functions. It does not change a component into a Server Component.
Exercises
- Convert a controlled submit workflow to a form Action.
- Return validation errors through
useActionState. - Move submit pending UI into a child
SubmitButtonusinguseFormStatus. - Add an optimistic task and reconcile it with the server result.
- Compare an optimistic Action with a TanStack Query v5 mutation design.
- Build tab panels with Activity and explain when preserving state is useful.
Exit questions
- What makes an Action different from an ordinary async function?
- What does
useActionStatereturn? - How does
useFormStatusfind the relevant form? - What is optimistic state?
- Why do Actions not replace authorization or a query cache?
- What problem does
<Activity>solve?
Official references
- https://react.dev/reference/react/useActionState
- https://react.dev/reference/react/useOptimistic
- https://react.dev/reference/react-dom/hooks/useFormStatus
- https://react.dev/reference/react/Activity
- https://react.dev/blog/2025/10/01/react-19-2
Deep dive: Actions unify async transition semantics, not server ownership
React Actions make some asynchronous workflows, particularly form submissions, easier to express. They do not take ownership of the server-side concerns around those workflows.
An Action does not tell you:
- where server data is cached;
- who is authorized to make the request;
- when data becomes stale;
- how pagination is coordinated;
- whether a write is idempotent.
Those remain separate design concerns. The useful distinction is between coordinating the UI transition and owning the data or security boundary.
Function form action lifecycle
function TaskForm() {
async function create(formData) {
const title = String(formData.get('title') ?? '').trim();
await saveTask({ title });
}
return (
<form action={create}>
<input name="title" />
<SubmitButton />
</form>
);
}
When the form Action succeeds, React can reset uncontrolled fields as part of the applicable form-Action behavior. This is behavior to understand, not a reason to assume that every form will reset identically in every framework integration.
If you control fields manually, you still own their reset state. React cannot infer the product's intended reset behavior from arbitrary controlled state. Test the exact behavior for the React and framework versions you use instead of relying on assumptions about ordinary browser form submission.
useFormStatus details
A descendant can inspect the nearest form submission:
function SubmitButton() {
const { pending, data, method, action } = useFormStatus();
return (
<button disabled={pending}>
{pending ? 'Creating…' : 'Create task'}
</button>
);
}
The component calling useFormStatus must be inside the form tree. The component that renders the <form> itself cannot use the Hook to read that form's status before the form exists as its parent. Put status UI in a descendant such as SubmitButton.
This scope rule explains a common debugging symptom: if the button never reflects the form's pending state, first verify that the button component is actually rendered below the intended <form> rather than beside it or above it.
useActionState previous state
The Action signature is:
async function action(previousState, formData) {
...
return nextState;
}
The first argument is the state produced by the previous submission, and the second is the current FormData. That makes the Hook useful for accumulated form-result state, including field values, field errors, and a summary message.
Here is a validation example:
const initialState = {
values: {
title: '',
},
errors: {},
message: '',
};
async function createTaskAction(previousState, formData) {
const title = String(formData.get('title') ?? '').trim();
if (title.length < 3) {
return {
values: { title },
errors: {
title: 'Use at least 3 characters.',
},
message: 'Check the form.',
};
}
const result = await api.createTask({ title });
return {
values: { title: '' },
errors: {},
message: `Created ${result.task.title}`,
};
}
The resulting object is form and UI state. It is not a replacement for a query cache. If another screen needs the created task or needs to know that a list is stale, update or invalidate the shared server-state cache as well.
Action errors
Before deciding how to handle a failure, classify it. An expected validation problem can be represented as structured form state:
expected validation
→ return structured state
An unexpected exception may instead need to be thrown and handled by the application's error boundary and monitoring architecture:
unexpected exception
→ throw / error boundary / monitoring as architecture dictates
Do not turn every server failure into an unhandled render exception. Conversely, do not catch every failure and present it as a friendly field error when the application needs to record, retry, or escalate an operational problem.
Optimistic UI correctness
Optimistic UI has three distinct truths:
predicted UI
request in progress
authoritative server result
The first is what the interface expects will happen. The second describes the request's current lifecycle. The third is the result that should ultimately determine the durable UI. A robust workflow accounts for transitions among all three rather than treating the prediction as success.
For a create operation, the basic optimistic record might be built like this:
const [optimisticTasks, addOptimisticTask] = useOptimistic(
tasks,
(current, optimisticTask) => [
...current,
optimisticTask,
],
);
The temporary record may need an explicit client identity and lifecycle status:
{
clientId,
title,
status: 'sending'
}
The server may return a different identity or normalized data:
{
id: 'server-123',
title: 'Normalized title'
}
Reconcile the IDs and data when that response arrives. Do not allow a temporary client ID to leak permanently into URLs or relationships unless the API explicitly supports client-generated IDs.
Rollback versus error annotation
When an optimistic request fails, the interface has several legitimate choices:
- remove the optimistic item;
- restore the previous value;
- keep the failed item with a Retry action;
- mark it as failed and let the user edit it;
- show a conflict resolver.
The right choice depends on what the user values and what the operation means. A chat message that disappears on a network failure may be frustrating, so retaining it with Retry can be the clearer design. For a destructive deletion, immediately rolling the item back may communicate the failure more honestly.
Activity versus conditional rendering
With conditional rendering:
{tab === 'editor' && <Editor />}
React removes Editor when the condition becomes false, cleaning up its state and Effects. That is exactly what you want when leaving the screen should reset it.
Activity's hidden mode can instead preserve the subtree's internal state while hiding it and deprioritizing its work. The trade-off involves:
- memory;
- preserved drafts;
- subscription lifecycle;
- privacy and sensitive fields;
- return speed.
Do not use Activity for a logout boundary where state must be destroyed. A hidden authenticated subtree can preserve information that should no longer be accessible to the previous user; unmount or reset sensitive subtrees when that is the required security behavior.
This is also relevant when switching accounts without a full page reload. Preserved state can contain drafts, selected records, or data loaded under the previous identity. Treat account changes as a deliberate state-lifecycle boundary rather than assuming that hiding the old panel is sufficient.
Activity and Effects
When an Activity becomes hidden, React can clean up Effects while preserving state, then restore the Effects when the Activity becomes visible again. This is more nuanced than applying CSS display: none to a mounted component.
Because libraries may have their own lifecycles, consult the current React and library documentation before assuming that every hidden subscription continues running or stops in exactly the same way. The state-preservation behavior and the Effect behavior are related, but they are not a license to ignore resource ownership.
Transition relationship
Actions use transition semantics, allowing React to coordinate pending rendering without treating the whole interaction as a block on urgent input. That helps React schedule the UI work associated with the asynchronous operation.
Scheduling does not make expensive synchronous JavaScript free. Long CPU-bound work inside an Action can still block the main thread. Move expensive computation or server work to an appropriate system instead of expecting a transition to remove its cost.
Progressive enhancement and framework Actions
In RSC-aware server frameworks, form Actions can integrate with Server Functions and progressive enhancement. The two ideas often appear together, but they are not the same abstraction.
React form Action
= UI async transition mechanism
Server Function
= privileged server boundary callable from client/framework
A React form Action describes how the UI submits and tracks work. A Server Function describes a server boundary with access to server capabilities. They can be composed, but one does not grant the other its security properties.
Server authorization example
This server-side operation is unsafe without an authorization check:
async function deleteTaskAction(formData) {
const id = formData.get('id');
return db.task.delete(id);
}
The fact that it is reached through a form or Server Function does not make the client-provided ID trustworthy. Conceptually, the server function should establish the user, load the record, compare ownership, and only then perform the delete:
const user = await requireUser();
const task = await db.task.findById(id);
if (task.ownerId !== user.id) {
throw new ForbiddenError();
}
await db.task.delete(id);
The client-provided ID is untrusted input. Authorization must be evaluated on the server for every operation, even if the UI hides the control from users who should not have access.
Action versus TanStack Query mutation decision
React Action is a good fit when:
- form and Action semantics are central to the interaction;
- the application uses framework Server Functions;
- pending and optimistic form state are sufficient.
TanStack Query mutation is a better fit when:
- the server cache needs invalidation or updates;
- many consumers observe the same resource;
- the mutation lifecycle needs cache coordination;
- offline or network modes, pagination, or infinite data matter.
Use them together when their responsibilities are explicit. There is no benefit in making one abstraction pretend to own the concerns of the other.
Failure clinic
Optimistic success message before server truth
Do not show a permanent “Saved” message until the authoritative operation succeeds. A pending request can fail, time out, or be rejected.
useFormStatus placed outside form
The Hook will not observe the intended submission if its component is outside the form tree. Check the rendered component hierarchy first.
Server Function treated as trusted internal call
The client can invoke a Server Function with manipulated arguments. Authorize every operation and validate every value that crosses the boundary.
Activity used to hide confidential state after logout
Preserving state may be the wrong behavior. Unmount or reset sensitive subtrees when a logout or account change requires the old state to be destroyed.
Deep-dive exercises
- Return field errors from
useActionState. - Build a reusable submit button using
useFormStatus. - Implement optimistic creation with a temporary ID and server reconciliation.
- Compare rollback with failed-item Retry UX.
- Replace conditional tab unmounting with Activity and observe state preservation.
- Write a server authorization checklist for one Action.
Mastery check
Explain all of the following in your own words:
- Action state versus server cache;
- optimistic prediction versus authority;
- the scope of
useFormStatus; - the meaning of
useActionState's previous state; - Activity's state-preservation trade-offs;
- why Server Functions still require authorization.
Production case study: optimistic comment submission with retryable failed state
A failed optimistic item that simply disappears can leave the user unsure whether the message was lost. For a comment workflow, it is often better to keep enough information to retry.
Model an optimistic comment like this:
{
clientId: 'local-1',
body: 'Looks good',
status: 'sending'
}
If the request fails, retain the comment and annotate the failure:
{
clientId: 'local-1',
body: 'Looks good',
status: 'failed',
error: 'Network unavailable'
}
The corresponding UI can expose both states:
<li>
<p>{comment.body}</p>
{comment.status === 'sending' && (
<span>Sending…</span>
)}
{comment.status === 'failed' && (
<>
<span role="alert">Not sent.</span>
<button onClick={() => retry(comment.clientId)}>
Retry
</button>
</>
)}
</li>
For this product, keeping the failed draft visible is more useful than making it vanish during rollback. That is not a universal optimism rule. For a bank transfer, showing optimistic success would be inappropriate because the cost of misleading the user is much higher.
The broader lesson is that useOptimistic supplies a mechanism. Product risk determines whether optimism is appropriate and how failure should be represented.
Additional depth: progressive enhancement and Action architecture
Function Actions are especially useful in frameworks that can accept a form submission before all client JavaScript has finished loading. Progressive enhancement works best when the form remains meaningful HTML rather than becoming dependent on a client-only click handler.
<form action={createTaskAction}>
<label htmlFor="title">Title</label>
<input id="title" name="title" required minLength={3} />
<button>Create</button>
</form>
The browser platform remains the foundation. React adds coordination around it:
pending state
Action state
optimistic state
transition coordination
Permalink concept
useActionState has an optional permalink mechanism for progressive-enhancement scenarios. When an Action can be submitted before hydration, a stable URL representation can help the framework preserve the intended result across navigation.
This is primarily framework-oriented. Do not introduce it simply because it exists; it becomes relevant when the application uses server-rendered progressive forms. Learners should nevertheless understand why the API exposes the mechanism.
Action idempotency
A disabled pending button reduces accidental duplicate clicks, but it cannot guarantee that the server receives only one request. Requests can be retried, users can open multiple tabs, networks can duplicate work, and a client can bypass the UI entirely.
Operations such as:
charge payment
create booking
place order
may require idempotency keys or transactional uniqueness on the server. React's pending state is a UX measure, not protection for a distributed system.
Optimistic risk classification
Some operations are relatively safe to represent optimistically:
favorite toggle
task checkbox
local comment display
Other operations carry enough risk that speculative success is inappropriate:
payment succeeded
inventory reserved
legal approval recorded
refund completed
For high-integrity operations, show that the request is pending and wait for authoritative success. Choosing optimism is a product and risk decision, not merely a preference between Hooks.
