useActionState is a React 19 hook that turns an async function into a piece of state: every dispatch folds the previous state into the next one. You give it an async action(previousState, payload) and an initial state; it hands back the current state, a dispatch function, and an isPending flag. Because the action receives the last result as its first argument, successive dispatches accumulate — which is exactly what a form needs, where each submit should see the errors and values from the submit before it. It is, in one line, an async reducer with a built-in pending flag, designed to back <form action={dispatch}>.
function useActionState<S>(
action: (previousState: S, payload: unknown) => Promise<S> | S,
initialState: S,
): [state: S, dispatch: (payload: unknown) => Promise<void>, isPending: boolean];
The action's first argument is the previous state — initialState on the first call, then its own last return value. Whatever it resolves to becomes the new state. dispatch is stable across renders and returns a promise you can await.
// Folding a number: each dispatch sees the running total.
const [count, dispatch] = useActionState(async (prev, amount) => prev + amount, 0);
dispatch(1); // action(0, 1) -> state becomes 1
dispatch(5); // action(1, 5) -> state becomes 6 (folds the LATEST state, not 0)
// A form action returning { values, error }: the next submit sees the last result.
const [result, submit, isPending] = useActionState(
async (prev, formData) => {
const email = formData.get('email');
return { email, error: email ? null : 'Required', tries: prev.tries + 1 };
},
{ email: '', error: null, tries: 0 },
);
// <form action={submit}> — submit twice and `tries` reads 2, because the second
// run receives the first run's { email, error, tries } as previousState.
dispatch must call action with the latest committed state, so two dispatches in a row chain (the second sees the first's result) instead of both folding off initialState.isPending is true from the moment you dispatch until the action settles, and returns to false whether it resolves or throws.state at its last committed value. React cancels any queued dispatches; pick and document your contract.dispatch keeps one identity across renders, so passing it to <form action={dispatch}> never re-subscribes.useState / useRef; do not import React's own useActionState.You are building an async reducer: a hook where each dispatch runs a function that takes the previous state and returns the next one, with a flag that stays on while it runs.
Think of a login form. The user submits, you hit the server, and it comes back with an error and the values to refill. They fix the typo and submit again — and this second submit needs to know it is the second one, so it can show a different message or count the attempts. That means every run has to start from the result of the run before it, not from a blank slate. useActionState is the hook that threads that result through for you: you write action(previousState, payload), it hands the last return value back as previousState next time, and it flips isPending on for the duration.
Picture a loop that never lets go of the state. A dispatch drops a payload in; the action combines it with the state that is already there and produces a new state; that new state is kept and becomes the input to the next dispatch. This is a fold — the same shape as Array.prototype.reduce, except each step is asynchronous and driven by a dispatch instead of an array element.
The obvious version holds the state and a pending flag with useState, and lets dispatch read the state, await the action, and commit the result:
const { useState } = require('react');
function useActionState(action, initialState) {
const [state, setState] = useState(initialState);
const [isPending, setIsPending] = useState(false);
const dispatch = async (payload) => {
setIsPending(true);
const next = await action(state, payload); // `state` from this render's closure
setState(next);
setIsPending(false);
};
return [state, dispatch, isPending];
}
It handles a single dispatch. But state here is a closure variable — a value captured from the render that created this dispatch. Fire two dispatches from the same reference before React re-renders, and both read the same captured state, so the second folds off initialState instead of the first result. A form holds one dispatch and calls it repeatedly, so this is the common case, not an edge case. Two more cracks: dispatch is a brand-new function every render (so <form action={dispatch}> re-subscribes constantly), and a thrown action skips setIsPending(false), leaving the flag stuck on.
The fix has two moving parts: keep the latest state in a ref so the fold always reads what was last committed, and run dispatches through a small queue so concurrent ones chain instead of racing.
const { useState, useRef, useCallback } = require('react');
function useActionState(action, initialState) {
const [state, setState] = useState(initialState);
const [isPending, setIsPending] = useState(false);
// Refs give us stable identity AND fresh reads. actionRef always holds the
// newest action; stateRef is the fold accumulator — the latest committed
// state, updated the instant an action resolves so the next fold sees it.
const actionRef = useRef(action);
actionRef.current = action;
const stateRef = useRef(initialState);
const queueRef = useRef([]);
const runningRef = useRef(false);
const dispatch = useCallback((payload) => {
queueRef.current.push(payload);
// A drain is already running — it will pick this payload up in its loop.
if (runningRef.current) return;
runningRef.current = true;
setIsPending(true);
const drain = async () => {
try {
while (queueRef.current.length > 0) {
const next = queueRef.current.shift();
// Fold the LATEST accumulator, not a render-time snapshot.
const result = await actionRef.current(stateRef.current, next);
stateRef.current = result; // commit before the next iteration chains
setState(result);
}
} catch (error) {
queueRef.current = []; // React cancels all queued actions on a throw
throw error; // surface it; state stays at the last committed value
} finally {
runningRef.current = false;
setIsPending(false);
}
};
return drain();
}, []);
return [state, dispatch, isPending];
}
module.exports = { useActionState };
The shift is that state now lives in two places: state (the reactive copy that triggers renders) and stateRef.current (the synchronous copy the fold reads). The ref is updated inside the loop, one line before setState, so the next iteration — or the next dispatch — reads the value that was just committed. dispatch is wrapped in useCallback with an empty dependency array, so it keeps one identity forever; it reaches the current action through actionRef rather than by closing over it. The try/finally guarantees isPending clears whether the run resolves or throws.
Start with initialState = 0, so state and stateRef.current are both 0, isPending is false. Grab dispatch once — the same way a form holds one reference.
dispatch(1). The payload 1 goes on the queue. Nothing is running, so we set runningRef and isPending to true and start draining. The loop shifts 1, reads stateRef.current (0), and awaits action(0, 1). The page now shows a spinner because isPending is true.1. We set stateRef.current = 1 first, then setState(1). The queue is empty, so the loop exits, finally clears runningRef and isPending, and React re-renders with state = 1.dispatch(5) — the same reference. Payload 5 is queued, a fresh drain starts. The loop reads stateRef.current, which is 1, not 0, and awaits action(1, 5).6. stateRef.current = 6, setState(6), pending clears. The second dispatch folded off the first result — that is the behavior the naive closure version gets wrong.The pending flag traces a simple lifecycle across all of this: it is off at rest, flips on when a drain begins, and flips off when the queue empties — on success or on a throw.
state from the render scope makes back-to-back dispatches both start from initialState; submit a form twice quickly and the attempt counter reads 1, not 2. Fix: keep the latest committed state in a ref and fold off stateRef.current.dispatch each render means <form action={dispatch}> re-binds on every keystroke, and any child memoized on it re-renders. Fix: useCallback(..., []) plus refs for everything it reads.try/finally, a rejected action never reaches setIsPending(false) and the button spins forever. Fix: clear pending in a finally so both outcomes settle it.stateRef and the later commit clobbers the earlier one. Fix: a runningRef flag plus a queue, so the second payload waits and folds off the first's result.action inside useCallback([]) pins the first render's function, so an action that reads fresh props goes stale. Fix: store it in actionRef and read actionRef.current.{ error } as part of state so a field can render it — most forms choose the latter. Our version throws and cancels; returning error state is a one-line change to the action, not the hook.permalink argument. The real hook takes an optional third argument, a URL used for progressive enhancement with Server Components so a form works before JavaScript loads. It has no effect on the client-only behavior you built here.isPending is really a Transition. In React, dispatch runs inside a Transition and isPending reflects it, which is why dispatch must be called from a form action or startTransition. Your userland flag tracks the async run directly — same observable result, simpler mechanism.useOptimistic. To show the new value immediately instead of waiting for the action, layer useOptimistic on top so the UI updates on dispatch and reconciles when the fold commits.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useActionState is a React 19 hook that turns an async function into a piece of state: every dispatch folds the previous state into the next one. You give it an async action(previousState, payload) and an initial state; it hands back the current state, a dispatch function, and an isPending flag. Because the action receives the last result as its first argument, successive dispatches accumulate — which is exactly what a form needs, where each submit should see the errors and values from the submit before it. It is, in one line, an async reducer with a built-in pending flag, designed to back <form action={dispatch}>.
function useActionState<S>(
action: (previousState: S, payload: unknown) => Promise<S> | S,
initialState: S,
): [state: S, dispatch: (payload: unknown) => Promise<void>, isPending: boolean];
The action's first argument is the previous state — initialState on the first call, then its own last return value. Whatever it resolves to becomes the new state. dispatch is stable across renders and returns a promise you can await.
// Folding a number: each dispatch sees the running total.
const [count, dispatch] = useActionState(async (prev, amount) => prev + amount, 0);
dispatch(1); // action(0, 1) -> state becomes 1
dispatch(5); // action(1, 5) -> state becomes 6 (folds the LATEST state, not 0)
// A form action returning { values, error }: the next submit sees the last result.
const [result, submit, isPending] = useActionState(
async (prev, formData) => {
const email = formData.get('email');
return { email, error: email ? null : 'Required', tries: prev.tries + 1 };
},
{ email: '', error: null, tries: 0 },
);
// <form action={submit}> — submit twice and `tries` reads 2, because the second
// run receives the first run's { email, error, tries } as previousState.
dispatch must call action with the latest committed state, so two dispatches in a row chain (the second sees the first's result) instead of both folding off initialState.isPending is true from the moment you dispatch until the action settles, and returns to false whether it resolves or throws.state at its last committed value. React cancels any queued dispatches; pick and document your contract.dispatch keeps one identity across renders, so passing it to <form action={dispatch}> never re-subscribes.useState / useRef; do not import React's own useActionState.You are building an async reducer: a hook where each dispatch runs a function that takes the previous state and returns the next one, with a flag that stays on while it runs.
Think of a login form. The user submits, you hit the server, and it comes back with an error and the values to refill. They fix the typo and submit again — and this second submit needs to know it is the second one, so it can show a different message or count the attempts. That means every run has to start from the result of the run before it, not from a blank slate. useActionState is the hook that threads that result through for you: you write action(previousState, payload), it hands the last return value back as previousState next time, and it flips isPending on for the duration.
Picture a loop that never lets go of the state. A dispatch drops a payload in; the action combines it with the state that is already there and produces a new state; that new state is kept and becomes the input to the next dispatch. This is a fold — the same shape as Array.prototype.reduce, except each step is asynchronous and driven by a dispatch instead of an array element.
The obvious version holds the state and a pending flag with useState, and lets dispatch read the state, await the action, and commit the result:
const { useState } = require('react');
function useActionState(action, initialState) {
const [state, setState] = useState(initialState);
const [isPending, setIsPending] = useState(false);
const dispatch = async (payload) => {
setIsPending(true);
const next = await action(state, payload); // `state` from this render's closure
setState(next);
setIsPending(false);
};
return [state, dispatch, isPending];
}
It handles a single dispatch. But state here is a closure variable — a value captured from the render that created this dispatch. Fire two dispatches from the same reference before React re-renders, and both read the same captured state, so the second folds off initialState instead of the first result. A form holds one dispatch and calls it repeatedly, so this is the common case, not an edge case. Two more cracks: dispatch is a brand-new function every render (so <form action={dispatch}> re-subscribes constantly), and a thrown action skips setIsPending(false), leaving the flag stuck on.
The fix has two moving parts: keep the latest state in a ref so the fold always reads what was last committed, and run dispatches through a small queue so concurrent ones chain instead of racing.
const { useState, useRef, useCallback } = require('react');
function useActionState(action, initialState) {
const [state, setState] = useState(initialState);
const [isPending, setIsPending] = useState(false);
// Refs give us stable identity AND fresh reads. actionRef always holds the
// newest action; stateRef is the fold accumulator — the latest committed
// state, updated the instant an action resolves so the next fold sees it.
const actionRef = useRef(action);
actionRef.current = action;
const stateRef = useRef(initialState);
const queueRef = useRef([]);
const runningRef = useRef(false);
const dispatch = useCallback((payload) => {
queueRef.current.push(payload);
// A drain is already running — it will pick this payload up in its loop.
if (runningRef.current) return;
runningRef.current = true;
setIsPending(true);
const drain = async () => {
try {
while (queueRef.current.length > 0) {
const next = queueRef.current.shift();
// Fold the LATEST accumulator, not a render-time snapshot.
const result = await actionRef.current(stateRef.current, next);
stateRef.current = result; // commit before the next iteration chains
setState(result);
}
} catch (error) {
queueRef.current = []; // React cancels all queued actions on a throw
throw error; // surface it; state stays at the last committed value
} finally {
runningRef.current = false;
setIsPending(false);
}
};
return drain();
}, []);
return [state, dispatch, isPending];
}
module.exports = { useActionState };
The shift is that state now lives in two places: state (the reactive copy that triggers renders) and stateRef.current (the synchronous copy the fold reads). The ref is updated inside the loop, one line before setState, so the next iteration — or the next dispatch — reads the value that was just committed. dispatch is wrapped in useCallback with an empty dependency array, so it keeps one identity forever; it reaches the current action through actionRef rather than by closing over it. The try/finally guarantees isPending clears whether the run resolves or throws.
Start with initialState = 0, so state and stateRef.current are both 0, isPending is false. Grab dispatch once — the same way a form holds one reference.
dispatch(1). The payload 1 goes on the queue. Nothing is running, so we set runningRef and isPending to true and start draining. The loop shifts 1, reads stateRef.current (0), and awaits action(0, 1). The page now shows a spinner because isPending is true.1. We set stateRef.current = 1 first, then setState(1). The queue is empty, so the loop exits, finally clears runningRef and isPending, and React re-renders with state = 1.dispatch(5) — the same reference. Payload 5 is queued, a fresh drain starts. The loop reads stateRef.current, which is 1, not 0, and awaits action(1, 5).6. stateRef.current = 6, setState(6), pending clears. The second dispatch folded off the first result — that is the behavior the naive closure version gets wrong.The pending flag traces a simple lifecycle across all of this: it is off at rest, flips on when a drain begins, and flips off when the queue empties — on success or on a throw.
state from the render scope makes back-to-back dispatches both start from initialState; submit a form twice quickly and the attempt counter reads 1, not 2. Fix: keep the latest committed state in a ref and fold off stateRef.current.dispatch each render means <form action={dispatch}> re-binds on every keystroke, and any child memoized on it re-renders. Fix: useCallback(..., []) plus refs for everything it reads.try/finally, a rejected action never reaches setIsPending(false) and the button spins forever. Fix: clear pending in a finally so both outcomes settle it.stateRef and the later commit clobbers the earlier one. Fix: a runningRef flag plus a queue, so the second payload waits and folds off the first's result.action inside useCallback([]) pins the first render's function, so an action that reads fresh props goes stale. Fix: store it in actionRef and read actionRef.current.{ error } as part of state so a field can render it — most forms choose the latter. Our version throws and cancels; returning error state is a one-line change to the action, not the hook.permalink argument. The real hook takes an optional third argument, a URL used for progressive enhancement with Server Components so a form works before JavaScript loads. It has no effect on the client-only behavior you built here.isPending is really a Transition. In React, dispatch runs inside a Transition and isPending reflects it, which is why dispatch must be called from a form action or startTransition. Your userland flag tracks the async run directly — same observable result, simpler mechanism.useOptimistic. To show the new value immediately instead of waiting for the action, layer useOptimistic on top so the UI updates on dispatch and reconciles when the fold commits.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.