next-safe-action
Guides

Coordinating mutations

useOptimisticAction is last-write-wins: when two executions overlap, the newer response is kept and the older one is discarded. That is the right behaviour for replace semantics, like saving a title.

It is the wrong behaviour for accumulate semantics. If a user drags an item, then drags it again before the first save finishes, the second write must build on the result of the first. Dropping a response there loses information.

useOptimisticStateAction is the optimistic hook for stateful actions. It is built on React's useActionState, so dispatches are queued: each one waits for the previous to settle and receives its result as prevResult.

Pick by action kind, not by concurrency model. .action() pairs with useOptimisticAction, .stateAction() pairs with useOptimisticStateAction, exactly as useAction pairs with useStateAction.

The model

There is one idea to hold on to: a confirmed base, folded with every in-flight change.

optimisticState = updateFn(updateFn(confirmed, change1), change2)

Confirmed state is the more recent of:

  1. the action's successful data, and
  2. the currentState option.

That single rule covers every arrangement. An action that returns the full next state owns the confirmed value. An action that returns nothing leaves currentState authoritative. And when a Server Component revalidates, the fresh currentState beats a stale client-side fold, which is what you want: the server is the source of truth.

Recency is measured by arrival: any new currentState identity supersedes the committed result. So every action that writes the state the page renders must revalidate it, even one that already returns the next state.

If one action revalidates and another does not, the newest payload the page receives can be a snapshot taken before the newest write, and it still wins. The saved change then disappears from the UI while the server keeps it. The tell is result.data and optimisticState disagreeing after a save settles.

Reduced state

The action returns the full next state, and the same reducer runs on the client and the server.

Share the reducer

src/features/layout/reducer.ts
export type LayoutChange =
	| { type: "rename"; id: string; name: string }
	| { type: "move"; id: string; toGroup: string; toIndex: number };

export function layoutReducer(groups: Group[], change: LayoutChange): Group[] {
	switch (change.type) {
		case "rename":
			return groups.map((g) => (g.id === change.id ? { ...g, name: change.name } : g));
		case "move":
			return moveChannel(groups, change);
	}
}

Define the stateful action

prevResult.data is always the last confirmed state. The hook substitutes it when a dispatch fails, so one rejected write cannot leave the rest of the queue without a base to build on.

src/app/actions.ts
"use server";

export const saveLayout = actionClient
	.inputSchema(layoutChangeSchema)
	.stateAction(async ({ parsedInput }, { prevResult }) => {
		const next = layoutReducer(prevResult.data!, parsedInput);
		await db.layout.save(next);
		return next;
	});

Use the hook

src/app/channel-nav.tsx
"use client";

import { useOptimisticStateAction } from "next-safe-action/hooks";

export function ChannelNav({ groups }: { groups: Group[] }) {
	const { optimisticState, execute, isPending } = useOptimisticStateAction(saveLayout, {
		currentState: groups,
		updateFn: layoutReducer,
	});

	return (
		<nav aria-label="Channels" data-saving={isPending}>
			{optimisticState.map((group) => (
				<ChannelGroup key={group.id} group={group} onChange={execute} />
			))}
		</nav>
	);
}

Every change renders immediately, saves run one after another, and a failed save converges back to the last confirmed layout with no reverse change to write.

Pending changes list

When several components need the same in-flight changes, keep the confirmed data in Server Components and let the hook hold only the pending list. Use a constant base and an append reducer:

src/providers/calendar-events-provider.tsx
"use client";

const NO_PENDING: EventChange[] = [];

export function CalendarEventsProvider({ children }: { children: React.ReactNode }) {
	const { optimisticState: pendingChanges, execute } = useOptimisticStateAction(saveEventChange, {
		currentState: NO_PENDING,
		updateFn: (changes, change) => [...changes, change],
	});

	return (
		<PendingChangesContext value={pendingChanges}>
			<DispatchContext value={execute}>{children}</DispatchContext>
		</PendingChangesContext>
	);
}

// Consumers fold the pending changes over their own server data.
export function useOptimisticEvents(events: CalendarEvent[]) {
	return use(PendingChangesContext).reduce(eventChangeReducer, events);
}

The action returns nothing, so confirmed state stays at the constant base and the list drains as the queue settles. No explicit rollback is needed: discarding the temporary state is the rollback.

Because the action returns no data, the server data must be revalidated, with revalidatePath or revalidateTag. A fresh currentState is the only thing that can move confirmed state in this shape. Without it the pending list drains onto unchanged data and every saved change visibly reverts.

This variant still needs .stateAction(), even though it ignores prevResult. Queueing comes from useActionState, which the stateful path uses.

prevResult.data here carries the constant base, not real domain state, so actions in this shape should ignore it.

What counts as "more recent"

Confirmed state is the more recent of the action's data and currentState, and the hook answers "more recent" differently for the two things it feeds:

  • What you render goes by arrival. Any new currentState identity supersedes the committed result. A payload that was rendered before the newest write still wins, which is why every action that writes the state you render has to revalidate it.
  • The base the next queued dispatch sends to the server goes by write order. A currentState that commits while an action is still running was rendered before that action wrote, so the action's own data wins when it settles. A currentState that arrives once nothing is in flight wins instead.

The second rule matters when a payload arrives mid-queue: it usually acknowledges the dispatch before the running one, and treating it as newer would drop the running dispatch's write and let the dispatch after it overwrite the change.

currentState is compared by identity

Pass a stable reference. A value with a new identity on every render means confirmed state can never advance past it, so the fold drains on every commit and the change appears to revert.

An inline literal is the obvious case, but a derived value breaks in exactly the same way, and looks safe because it comes from props:

const NO_PENDING: EventChange[] = [];              // hoisted, stable

useOptimisticStateAction(saveLayout, {
	currentState: groups,                            // fine: the prop identity is stable
	// currentState: [],                             // new array every render
	// currentState: groups.filter(g => g.visible),  // also new every render
	updateFn: layoutReducer,
});

Filter or sort before passing the value in, or memoise it. A fresh identity is read as "the server sent newer data", so an unstable one says that on every render.

Callbacks fire per dispatch

While a queue drains, React withholds useActionState's commit until the last action settles, so result and status cannot report intermediate results. This hook therefore delivers onExecute, onSuccess, onError, onSettled, and onNavigation per dispatch, from the dispatch itself rather than from a render effect.

useOptimisticStateAction(saveLayout, {
	currentState: groups,
	updateFn: layoutReducer,
	onError: ({ error, input }) => toast.error(`Could not save ${input.id}`),
});

The trade-off: these fire just before React commits, where the other hooks' callbacks fire after. Read state from the callback argument rather than from the previous render.

One consequence is worth designing around: a failed dispatch reports its error immediately, while the failed change stays on screen until the whole queue drains. So an error toast can appear seconds before the user sees the change roll back. If that reads as broken in your UI, mark the affected rows as failed from onError instead of relying on the rollback alone.

Queueing costs one round trip per change

Serialization is the point of this hook, and it has a price: N changes are N sequential requests. It is the right trade when changes build on each other, and the wrong one when they are independent.

Splitting into one hook per entity gives each entity its own prevResult chain, but it does not buy network parallelism: Next.js runs Server Actions through a single global queue in the App Router, so requests still go one at a time. If round trips are the bottleneck, batch or coalesce compatible changes into one action instead.

When to reach for a data library instead

This pattern assumes confirmed data lives in Server Components and the optimistic layer disappears when the action finishes. If your data changes independently of the user, for example messages arriving over a socket, use TanStack Query or SWR instead.

Rollback is client-side only

A rejected dispatch reverts the UI. It does not prove the server changed nothing: output validation runs after your server code returns, so a write can land and still surface an error. Durable correctness still needs transactions, idempotency keys, or authoritative revalidation.

The queue assumes you are the only writer

Every dispatch sends the client's confirmed state as prevResult, and your action writes the next state from it. That is last-write-wins by construction, and it is fine while the user is the only person changing this data.

It is not fine with a second writer, for example another tab, another user, or a background job. Say an external change lands while one of your dispatches is in flight:

  • The page shows it, because a new currentState always supersedes the committed result.
  • The next queued dispatch does not build on it. It builds on what the running action returned, which is what the server held after that write, and the external change was already overwritten there.

So the client does not invent a conflict, it reports one that the server already resolved in favour of the later write. If losing that change is not acceptable, the resolution has to happen on the server: write a delta instead of the whole state, or guard the write with a version column, an updatedAt check, or a transaction. No client-side base can fix it, because the client cannot know what reached the server first.

If data changes independently of the user often enough to matter, this hook is the wrong tool. See When to reach for a data library instead.

A currentState that arrives mid-queue

React holds an optimistic payload only while its own dispatch is pending. This hook keeps every payload alive for the whole queue instead, which is what lets three overlapping moves stay on screen together. That works because the confirmed base does not normally advance until the queue drains: in Next, the RSC payload of a completed write commits on the same suspended lane the queue is waiting on, so it lands with everything else.

A currentState that commits while the queue still has work is the exception. An urgent update does this: a socket push, a router.refresh() outside a transition, or a parent that re-renders with a new value. The base then moves under payloads that are still attached, and the ones whose dispatch has already settled are already accounted for in the new base.

Those payloads stop folding at that point. The pending ones keep folding, so the change the user is still waiting on stays visible. You do not have to do anything for this, but it explains why an external revision arriving mid-queue does not double-count the writes it already carries.

reset drops the queue, but not the dispatch already in flight

reset() returns the hook to its mount-time baseline and marks every dispatch made before it as discarded. For the queued hooks that also means the writes themselves:

  • The dispatch that is already talking to the server completes. It cannot be recalled, so its write lands, and a revalidatePath inside it can still push a fresh currentState.
  • Every dispatch still waiting its turn in the queue is skipped. Nothing was sent for it yet, so the action never runs and no write happens. Its executeAsync promise resolves with {}, and none of its callbacks fire.

Skipping them is what keeps a reset stable. If they ran, each one would write and revalidate, and those payloads would arrive after the reset and pull the confirmed state into an order the user had already discarded.

What's next?

On this page