React 19 Actions Are Not a State Management Solution
The new Actions API is a solid improvement for handling async mutations and form submissions. But teams are already reaching for it as a Zustand or Redux replacement, and that's a category error that will bite them at scale.
Every New React Feature Kills Redux. Apparently.
I've been writing React since the class component days, and if I had a rupee for every time something was declared the "Redux killer," I'd have retired to some hill station by now. Context API was going to kill Redux. Hooks were going to kill Redux. React Query was going to kill Redux. Now, with React 19 out, it's Actions' turn to be handed the trophy.
So let me be clear about what React 19 actually shipped. useTransition now accepts async functions natively. useActionState coordinates pending and error states around a mutation. And useOptimistic handles speculative UI updates with a grace that previously required either a third-party library or some very careful, very fragile hand-written code. These are thoughtful additions. The React team clearly spent serious time on them, and it shows.
But here's what's happening out there. Within weeks of the release, threads started appearing with titles like "React 19 Finally Kills Redux" and "You Don't Need Zustand Anymore." Engineers are dropping state libraries from their package.json. Senior folks are putting together migration proposals for architectural review. In doing so, they are solving the wrong problem with the right tool.
"Actions are React's answer to a specific question: how should a component coordinate an async mutation? They are not an answer to: how should shared application state flow through a large component tree?"
I'm not writing this to dismiss Actions at all. I use them in my own projects and they genuinely save time. I'm writing this because the conflation of "async mutation coordination" with "state management" is a conceptual mistake, and it's the kind of mistake that produces codebases which become quite painful to maintain a year down the line. Better to talk about it now, before the migration PRs get merged.
What the API Actually Does, Without the Marketing
Before arguing about what Actions are not, I want to be precise about what they are, with real code. Conceptual debates without code tend to generate more heat than light in frontend discussions, and I'd rather just show you.
React 19 defines an Action as an async function passed to a transition. The runtime then takes care of three things on your behalf: tracking whether the async work is pending, surfacing any errors that come up, and through useOptimistic, applying a speculative update to local state while the request is in flight.
useActionState in Practice
The main hook is useActionState. You pass it an action function and an initial state value, and it gives you back a tuple of the current state, a dispatch function, and a boolean indicating whether the action is currently running.
import { useActionState } from 'react'; async function submitProfileUpdate(prevState, formData) { try { const result = await updateProfile({ name: formData.get('name'), bio: formData.get('bio'), }); return { success: true, data: result, error: null }; } catch (e) { return { success: false, data: null, error: e.message }; } } function ProfileForm() { const [state, dispatch, isPending] = useActionState( submitProfileUpdate, { success: false, data: null, error: null } ); return ( <form action={dispatch}> <input name="name" defaultValue="Divyansh" /> <textarea name="bio" /> <button disabled={isPending}> {isPending ? 'Saving...' : 'Save Profile'} </button> {state.error && <p className="error">{state.error}</p>} </form> ); }
This is clean. Compare it to what we were writing before React 19: one useState for loading, another for error, another for data, a useRef to prevent double-submission, and a try/catch/finally to wire it together without leaving things in some inconsistent in-between state. Actions collapse all of that ceremony into something actually readable. No complaints from me here.
useOptimistic: Speculative UI Done Properly
useOptimistic deserves equal attention. It lets you apply a temporary speculative update while an async operation is in flight, and it automatically rolls back when the operation resolves or rejects. No more snapshot-and-restore logic written by hand in a catch block.
function LikeButton({ post }) { const [optimisticLikes, setOptimisticLikes] = useOptimistic( post.likes, (current, delta) => current + delta ); const [_, dispatch, isPending] = useActionState( async () => { setOptimisticLikes(+1); await toggleLike(post.id); }, null ); return ( <button onClick={dispatch} disabled={isPending}> ♥ {optimisticLikes} </button> ); }
Again, this is excellent. The old pattern required carefully juggling local state with server-validated state, and writing explicit rollback logic in the catch block. I've written that pattern myself more times than I'd like to count. useOptimistic makes it first-class and the code reads naturally.
The state in both examples is local to the component. The pending flag, the error message, the optimistic count delta -- these all belong entirely to one UI element doing one async thing. No other component needs to know about any of it. No cross-tree coordination is required at all.
The moment another component, even a sibling, needs to react to this state, you are outside the design envelope of Actions. And that moment arrives much sooner than most teams anticipate.
Mixing Up Mutation Coordination With State Management
State management is, at its core, the problem of making shared mutable state readable and writable across a component tree in a predictable way. The key word is shared. A shopping cart that needs to show up in the header, the sidebar, a drawer, and a checkout page is a state management problem. A form submission button showing a spinner is not.
React's model has always separated these two concerns, even if it hasn't always made both of them ergonomic to work with. Local state via useState and useReducer handles component-internal concerns. Lifted state, context, and external stores handle the shared concerns. Actions sit firmly in the local-state category. They are not a new primitive for the shared-state category, and they were never intended to be.
Where the Confusion Comes From
I do understand why people get confused, to be fair. Actions return state. useActionState looks, syntactically, quite like useReducer. And there's a line of thinking that goes: "All state in a React app is just the accumulated result of past actions against some initial value, so isn't useActionState basically a general-purpose state machine?" Technically yes. Practically, no.
useReducer can also model any state machine. That doesn't make it a Zustand replacement. What matters is not whether a tool can theoretically solve a problem, but whether it gives you the right access patterns, coordination model, and debugging primitives for that specific problem. This distinction tends to get lost in the excitement around a shiny new release.
"A hammer can technically drive a screw if you hit it hard enough. Actions can technically hold shared state if you lift them high enough. Neither approach ages well under maintenance pressure."
And So the Prop-Drilling Problem Returns
Here's what actually happens when a team decides to use Actions as their primary state management layer. Someone builds a useActionState in a top-level component to manage the authenticated user's session data. They need that state in a deeply nested component somewhere else in the tree. Actions have no subscription mechanism, no selector model, no way for an arbitrary descendant to tap into just the slice of state it needs. So what happens? They prop-drill. Or they reach for Context.
Context with frequent updates is a well-documented performance problem in React. Every consumer of a Context value re-renders whenever the value changes. For session state that updates once a day, fine. For anything updating frequently -- a cart, a filter panel, a real-time feed -- it becomes a significant rendering bottleneck. You then add useMemo here, React.memo there, split the context into smaller pieces. And now you've reinvented a worse version of Zustand with more ceremony and no DevTools. This is the exact problem Zustand and Jotai were built to solve in the first place.
Categories in React
Actually Address
in Actions
as Global Context
Three Categories of State, and Where Actions Sit
To be precise about where Actions belong, it helps to have a working taxonomy of application state. Most production React apps contain three meaningfully different kinds of state, each with different requirements. I find this framing useful during code reviews and architecture discussions with my team, so let me share it here.
Category 1: Local UI State
This is state that belongs entirely to one component or a tightly coupled cluster of components. Whether a dropdown is open. Which tab is active. The value of an uncontrolled input. Whether a button is showing its loading state. This state doesn't need to be shared, doesn't need to survive navigation, and doesn't need to be serialisable or globally debuggable.
Actions live here. useActionState and useOptimistic are excellent tools for this category. They're designed for it, and they don't need to be anything more.
Category 2: Shared Client State
This is state that multiple components across the tree need to read or write. Authentication details, shopping cart contents, notification preferences, UI theme, filter and sort settings on a data-heavy page. It needs to be accessible without prop drilling, it needs to update efficiently without causing unnecessary re-renders in unrelated components, and it needs clear ownership and mutation semantics.
This is where state libraries live. Zustand, Jotai, Valtio, Redux Toolkit -- all of them address this category. They give you stores with subscription semantics, meaning components can opt into specific slices of state and re-render only when those slices change. Actions provide none of this. They have no store, no selector, no subscription mechanism. This is not a gap that will be filled in some future version. It is a deliberate design boundary.
Category 3: Server State
This is state that originates on the server: API responses, paginated lists, cached query results. It has a completely different lifecycle from client state. It needs to be fetched, kept fresh, invalidated on mutation, and potentially shared across navigations. TanStack Query, SWR, and React Router's loader API all exist for this specific category. A mutation in this category -- posting a comment, updating a record -- is precisely what Actions are built to trigger. But Actions don't manage the resulting server state. Cache invalidation and refetching still belongs to a server-state library.
The confusion happens because one Action can touch all three categories at once. When a user submits a checkout form, the action might update local UI state (show a confirmation message), update shared client state (empty the cart), and invalidate server state (the order history cache). Actions handle the first part well. For the second and third, they hand off to the tools built for those jobs.
Actions vs. State Libraries: Putting It Side by Side
Let me make the capability differences concrete. The table below compares Actions against Zustand specifically. I'm using Zustand rather than Redux so we can sidestep the "but Redux has too much boilerplate" conversation, which is a separate debate entirely. Zustand is minimal, widely adopted, and a fair representative of what modern state libraries actually offer.
| Capability | useActionState | Zustand |
|---|---|---|
| Async mutation with pending state | ✓ First-class | ~ Manual / middleware |
| Optimistic UI rollback | ✓ Via useOptimistic | ~ Manual pattern |
| Native form integration (action=) | ✓ Built-in | ✗ Not applicable |
| Cross-component state sharing | ✗ Not supported | ✓ Core feature |
| Selector-based subscriptions | ✗ No subscription model | ✓ Granular re-render control |
| DevTools / time-travel debug | ✗ No tooling | ✓ Redux DevTools compatible |
| State survives navigation | ✗ Component-scoped | ✓ Middleware available |
| Read state outside React tree | ✗ Hook-only | ✓ store.getState() |
| Bundle size impact | ✓ 0 kB (built-in) | ~ ~1.1 kB gzipped |
| Server Actions (Next.js / RSC) | ✓ Native integration | ✗ Client-only |
Look at where Actions win: async mutation ergonomics, form integration, Server Action compatibility. These are real wins, worth appreciating and using. Now look at where they come up short: every dimension related to sharing, subscribing to, persisting, or debugging state across a component tree. The absence isn't an oversight or something that'll get patched. It's a deliberate design boundary.
The Right Pattern: Actions as the Mutation Layer
The most useful frame for React 19 Actions is not "state management alternative" but "mutation coordination layer." An Action is the glue between a user gesture and whatever side effects that gesture requires, including updating shared state. The Action triggers; the store holds. This isn't a compromise position -- it's the intended design.
A Cart Checkout Example
Let's look at a checkout flow. Cart state is shared because it needs to show in the header, the checkout page, and a confirmation drawer. A purchase action needs to hit an API, update the cart, and handle the pending and error states on the button itself. Here is how the two tools compose when used properly.
// store.ts -- Zustand owns shared cart state const useCartStore = create((set) => ({ items: [] as CartItem[], clearCart: () => set({ items: [] }), removeItem: (id: string) => set((s) => ({ items: s.items.filter(i => i.id !== id) })), })); // CheckoutButton.tsx -- Action owns the mutation ceremony function CheckoutButton() { const { items, clearCart } = useCartStore(); const [optimisticItems, setOptimisticItems] = useOptimistic(items); const [state, dispatch, isPending] = useActionState( async () => { setOptimisticItems([]); // optimistic: empty the cart immediately const order = await placeOrder(items); clearCart(); // confirmed: now update the Zustand store return { orderId: order.id }; }, null ); return ( <div> <CartPreview items={optimisticItems} /> <button onClick={dispatch} disabled={isPending}> {isPending ? 'Placing order...' : 'Place Order'} </button> {state?.orderId && <p>Order #{state.orderId} confirmed!</p>} </div> ); }
See how the division of labour works here. Zustand holds the cart items. Every component showing a cart badge, checkout summary, or drawer is subscribed to the same store and re-renders efficiently only when items change. The Action handles the async coordination: it fires the optimistic update, makes the network call, writes the confirmed change back to the store on success, and rolls back automatically on failure. Each tool is doing exactly what it's good at.
With Server Actions and TanStack Query
This three-tool pattern becomes even more natural in a Next.js App Router setup. useActionState coordinates the client-side pending and error UI. The Server Action runs on the server and revalidates the cache. TanStack Query handles the resulting refetch and shared cache invalidation on the client side. Each tool owns exactly one concern, and none of them step on each other's toes.
React Server Components do change this picture in one important way. A lot of what was previously shared client state, specifically fetched data, can now simply be server state rendered at the component level. RSC genuinely shrinks the "shared client state" category for many applications. You may need less Zustand if your data is server-rendered.
But please don't credit Actions for this. RSC is doing that work, not Actions. They happen to ship in the same major version, but they are separate features addressing separate problems.
What Goes Wrong When Teams Over-Reach
Teams that push Actions beyond their design envelope tend to land on one of three anti-patterns. I've seen all three show up in pull requests and Slack threads within weeks of React 19 going stable. Each one looks reasonable when it first appears in a codebase, and each one becomes a genuine maintenance headache as the codebase grows.
Anti-Pattern 1: The Context-Wrapped Action Store
A developer needs to share the result of a useActionState across components. The path of least resistance is to put it in Context. This works initially, but the moment the state starts updating frequently, every context consumer re-renders on every update, including the components that don't care about the changed slice. So the team adds useMemo. Then context splitting. And now they've reinvented a worse version of Zustand with more ceremony and zero DevTools support.
// ⚠ This pattern collapses under scale const CartContext = createContext(null); function CartProvider({ children }) { const [state, dispatch, isPending] = useActionState( cartReducerAction, initialCart ); // Every context consumer re-renders on every cart change. // Header badge, drawer, summary, checkout -- all re-render together. return ( <CartContext.Provider value={{ state, dispatch, isPending }}> {children} </CartContext.Provider> ); }
Anti-Pattern 2: The Chained Action Dependency Graph
A developer needs one async action to trigger another in sequence. Because Actions have no built-in composition model, they wire this together with useEffect watching the state of the first action to kick off the second. This is the classic derived-state-as-effect problem, one of React's most documented footguns, now reimplemented with a new API. When something in the chain fails, debugging it is just as painful as effect-chaining has always been. The new hook does not fix the underlying pattern.
Anti-Pattern 3: The Missing Rollback on Multi-Component Optimism
A developer applies useOptimistic in one component for a mutation whose results also appear in a sibling component. The sibling has no corresponding optimistic update. The mutation fails, rolls back in the first component automatically, but the sibling has already re-rendered from stale server state. The UI is now inconsistent. Because the Action's state is local, there is no single source of truth to roll back from. This bug is subtle, hard to reproduce in tests, and easy to miss in code review entirely.
"Optimistic state only rolls back where it was applied. If your optimistic update and your shared state live in different places, you have created a consistency problem for yourself."
A Practical Guide: When to Use What
The decision of when to reach for Actions, when to use a state library, and when to use a server-state tool is not philosophically complicated. It comes down to a few concrete questions about the specific state you're working with. I use something like this during code reviews when the choice isn't immediately obvious.
- Does any component outside this one need to read or write this state? If yes, you need a store. Actions are not the right fit here. If no, Actions may be all you need.
- Does this state need to survive a component unmount? If the user navigates away and comes back, should the state still be there? Actions reset on unmount. A store persists as long as the app is running.
- Is this state the result of a server fetch? If yes, it is server state. Use TanStack Query, SWR, or a router loader. Use an Action to trigger mutations against it, but not to hold the fetched value itself.
- Do you need to debug state changes over time? Actions leave no audit trail whatsoever. If you want DevTools support or time-travel debugging for this state, you need a store with DevTools integration.
- Is this a form submission that needs a loading state and an error display? This is precisely the Actions use case. Use
useActionStateand don't reach for a state library here -- that's overkill.
What React 19 Genuinely Makes Obsolete
I've spent most of this piece arguing about what Actions don't replace. Let me be equally direct about what they do replace, because there is real code that becomes unnecessary with React 19, and that is worth celebrating properly.
The Loading/Error useState Triplet
Every async component that wasn't using a data-fetching library had this pattern. Three separate useState calls -- isLoading, error, data -- manually set in the right order inside a try/catch/finally block. useActionState collapses this into one hook call with better semantics and fewer opportunities to leave state in an inconsistent condition during an error. This is a genuine improvement with no downsides.
Manual Optimistic State With Rollback
The pattern of snapshotting pre-mutation state, applying a speculative update, and conditionally rolling back inside a catch block was common and quite fragile. I've written it myself more times than I'd care to admit. useOptimistic makes this first-class. If your only reason for having a state library in a particular component was to enable this pattern locally, you may genuinely be able to remove that dependency.
Submission-Guarding Refs
The const isSubmitting = useRef(false) trick, used to prevent double-submission while an async action is in flight. useActionState's isPending handles this automatically. While an action is running, new dispatches are queued rather than duplicated. One less manual guard to forget to add.
Some Custom useAsync Hooks
Many codebases have a custom useAsync utility that wraps the loading/error/data triplet into something reusable. If that hook was mainly used for mutation coordination rather than data fetching, useActionState is a cleaner, more integrated replacement. If it was primarily used for fetching data, TanStack Query is still the better tool and nothing has changed on that front.
for Form Mutations
Actions Replace
Made Obsolete
Correctly Targeted
Respect the Boundary, and the API Gets Better
React 19 Actions are a meaningful quality-of-life improvement for a specific, real problem: coordinating the pending and error states of an async mutation in a form or UI element. They do this with less code, better semantics, and native form integration than anything React has offered before. The design is thoughtful and it shows.
The problem is not the feature. The problem is the story we tell around it. When a new API ships that reduces boilerplate, the community's first instinct is to expand its scope until it covers everything, including use cases it was never designed for. This is how Redux became a verb for "any state management at all," and it's how Actions risk becoming a verb for "any async anything."
I see this pattern repeat every couple of years. A genuinely useful tool ships. People overfit it to every problem they have. The backlash arrives. The tool gets labelled "overcomplicated" or "a mistake." Then someone writes a new library that does the thing the original tool was actually good at, and the cycle starts again. We'd collectively save ourselves a lot of trouble if we got better at reading the design intent of a tool before expanding its use.
Use Actions for what they're built for: async mutation coordination local to a component. Keep useOptimistic for instant UI feedback before the server confirms. Let TanStack Query own your fetched data. Keep your state library for shared client state. These tools are not competing with each other. They form a stack, and the stack is better than the sum of its parts precisely because each tool stays in its lane.
useActionState useOptimistic Zustand 5 TanStack Query v5 React Hook Form Zod
This stack covers all three state categories cleanly. Actions handle mutation coordination; Zustand handles shared client state with selector subscriptions and DevTools; TanStack Query manages server state with caching, background refetch, and optimistic updates for server-state mutations; React Hook Form manages form field state without causing unnecessary re-renders; Zod validates at the boundary. Nothing overlaps, everything composes, and six months from now you won't regret the choices.
Related essays
- Vite · Frontend Infrastructure
We Blamed Vite For A 90-Second Local Load. The Real Bottleneck Was The 11,516 Modules We Asked It For.
A local Vite page took 90 seconds to load because eager route imports pulled 11,516 modules into one page load. Making routes lazy cut it to 54 modules and 10.54 seconds. The mechanism, how to count your own modules, and where the fix stops working.
- React · Reconciliation
React's key Prop Is A Reset Button, Not An Identity Prop. Here Is What Most Engineers Get Wrong.
Every React tutorial teaches keys the same way - 'they help React tell items apart in a list'. That framing is why production React code is full of stale state, ghost animations, and forms that remember the last user's input. key is a reset primitive, not an identity marker.
- React · Security
Your React Server Components Are Leaking User Data Through Client Boundaries. Here's How.
Why the server/client boundary in React Server Components is a security minefield that most teams are crossing blindly - and how serialisation turns your careful access control into a data exposure incident waiting to happen.