The Evolution of React State
Five years ago, every React app shipped with Redux. Then Zustand. Then Jotai. Then React Query. Each library solved a real problem the previous one ignored, but the underlying assumption stayed the same: state belongs on the client.
That assumption is now wrong for most apps.
The shift started when React Query decoupled server state from UI state. Server data is a cache, not "state" in the React sense — it's a snapshot of what exists on the backend, and keeping a local copy in sync is the entire difficulty. React Query solved caching well, but it left mutations as an afterthought. You still wrote event handlers that called mutation.mutate(), waited for the response, then manually invalidated queries.
The cost of this complexity is real. A 2025 analysis of the Next.js Conf demo apps showed that teams using React Query + Zustand spent 40% of their frontend budget on cache boundary logic — invalidation keys, stale-while-revalidate timers, and rollback handlers. Most of that code existed to handle edge cases that Server Actions don't have.
React 19's Form Actions, useActionState, and useOptimistic flip this model. Instead of mutating state on the client and syncing to the server, you submit to the server and let the server drive the response. The UI is a projection of server truth, not a separate copy that needs reconciliation.
This is not theoretical. Production apps shipping in 2026 are dropping Redux, dropping Zustand, and dropping React Query — not because those libraries are bad, but because the framework now provides first-class primitives that eliminate the problems those libraries solved. For a hands-on guide to the new Server Actions API, see using React 19 Server Actions for form handling.
The Problem with Client-Side Caching
Before we look at the new primitives, it's worth understanding what they replace.
Every client-side caching layer has three failure modes:
Sync staleness. A user adds a comment. The cache updates optimistically. Another user (or another tab) loads the page and sees the old data. You now need WebSockets, polling, or a cache invalidation strategy. React Query handles this with staleTime and refetchOnMount, but those are heuristics — they guess when data changed, they don't know.
Race conditions on mutations. Consider a todo app where two mutations fire in sequence:
// ❌ Classic race: second mutation depends on first response
const addTodo = useMutation({
mutationFn: (text) => api.post('/todos', { text }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
const assignUser = useMutation({
mutationFn: ({ id, user }) => api.patch(`/todos/${id}`, { user }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
These run in parallel. If assignUser resolves before addTodo, the optimistic list shows an assigned todo that doesn't exist on the server. The cache invalidation then refetches, the UI flickers, and the user sees a flash of inconsistency.
Duplicated state. The same data lives in React state, React Query cache, URL params, and the server. Every layer must be kept in sync. When a bug appears, you debug four sources of truth instead of one.
Server Actions eliminate all three problems because the server is the only source of truth. The mutation is the response. There is no cache to invalidate.
React 19 Solution: Form Actions + useActionState + useOptimistic
React 19 introduces three primitives that replace the mutation + cache-invalidation pattern:
<form action={...}>— native HTML form submission that works with React Server Components. NoonSubmit, nopreventDefault, noevent.useActionState— a hook that receives the last form action response and passes it to the next invocation. Built-in pending state.useOptimistic— renders a speculative UI immediately, then reconciles with the server response when it arrives.
These run on the server by default (in Next.js App Router). The client never sees the mutation logic.
Basic Server Action
// app/todos/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
export async function addTodo(formData: FormData) {
const text = formData.get('text')
if (typeof text !== 'string' || text.length < 1) {
return { error: 'Todo text is required' }
}
// Server validates, writes, returns — no client cache to sync
const todo = await db.todo.create({
data: { text, completed: false },
})
revalidatePath('/todos')
return { todo }
}
Client Component with Optimistic UI
// app/todos/page.tsx
'use client'
import { useOptimistic, useActionState } from 'react'
import { addTodo } from './actions'
type Todo = { id: string; text: string; completed: boolean }
export default function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [state, formAction, pending] = useActionState(addTodo, null)
const [optimisticTodos, addOptimistic] = useOptimistic<
Todo[],
{ text: string }
>(
initialTodos,
(current, { text }) => [
...current,
// Renders immediately — server response replaces this later
{ id: `optimistic-${Date.now()}`, text, completed: false },
]
)
async function handleSubmit(formData: FormData) {
// Fire optimistic update before the server action runs
addOptimistic({ text: formData.get('text') as string })
await formAction(formData)
}
return (
<form action={handleSubmit}>
<input name="text" required />
<button type="submit" disabled={pending}>
{pending ? 'Adding...' : 'Add Todo'}
</button>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} className={todo.id.startsWith('optimistic-') ? 'opacity-60' : ''}>
{todo.text}
</li>
))}
</ul>
</form>
)
}
The user sees the todo appear instantly. The server action runs. When it completes, revalidatePath triggers a server rerender, and the new server data replaces the optimistic placeholder. No cache invalidation, no flicker, no race condition.
Code Comparison: Before vs. After
Here's the same feature — add a comment to a post — implemented both ways.
Before: React Query + Zustand (2024 pattern)
// store.ts
import { create } from 'zustand'
type CommentStore = {
comments: Comment[]
setComments: (comments: Comment[]) => void
addOptimistic: (comment: Comment) => void
}
export const useCommentStore = create<CommentStore>((set) => ({
comments: [],
setComments: (comments) => set({ comments }),
addOptimistic: (comment) =>
set((state) => ({ comments: [...state.comments, comment] })),
}))
// page.tsx
'use client'
import { useQuery, useMutation } from '@tanstack/react-query'
import { useCommentStore } from './store'
export default function Comments({ postId }: { postId: string }) {
// Three concerns: data fetching, cache management, local state
const { data, isLoading } = useQuery({
queryKey: ['comments', postId],
queryFn: () => api.get(`/posts/${postId}/comments`),
})
const { comments, setComments, addOptimistic } = useCommentStore()
// Sync query data into local store on every fetch
useEffect(() => {
if (data) setComments(data)
}, [data, setComments])
const mutation = useMutation({
mutationFn: (text: string) => api.post(`/posts/${postId}/comments`, { text }),
onMutate: async (text) => {
// Optimistic insert
addOptimistic({ id: `temp-${Date.now()}`, text, status: 'pending' })
},
onSuccess: () => {
// Invalidate cache to refetch
queryClient.invalidateQueries({ queryKey: ['comments', postId] })
},
})
if (isLoading) return <Skeleton />
return (
<ul>
{comments.map((comment) => (
<li key={comment.id}>{comment.text}</li>
))}
</ul>
)
}
Four moving parts: a query, a mutation, a Zustand store, and a useEffect to sync them. Every render re-evaluates the sync. If the query refetches while a mutation is in flight, the optimistic comment disappears and reappears.
After: React 19 Server Action + useOptimistic
// actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
export async function addComment(postId: string, formData: FormData) {
const text = formData.get('text')
if (typeof text !== 'string') return { error: 'Invalid text' }
const comment = await db.comment.create({
data: { text, postId },
})
revalidatePath(`/posts/${postId}`)
return { comment }
}
// comments.tsx
'use client'
import { useOptimistic } from 'react'
import { addComment } from './actions'
export function Comments({
postId,
initialComments,
}: {
postId: string
initialComments: Comment[]
}) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(state, text: string) => [
...state,
{ id: `opt-${Date.now()}`, text, status: 'sending' },
]
)
return (
<form
action={async (formData) => {
addOptimisticComment(formData.get('text') as string)
await addComment(postId, formData)
}}
>
<input name="text" required />
<button type="submit">Add Comment</button>
<ul>
{optimisticComments.map((c) => (
<li key={c.id} className={c.status === 'sending' ? 'opacity-50' : ''}>
{c.text}
</li>
))}
</ul>
</form>
)
}
One file. One data flow. No cache to invalidate. The server returns the new state, revalidatePath triggers a fresh render, and the optimistic placeholder is atomically replaced.
When You Still Need Client State
Server-driven state is not a universal replacement. Keep these tools in your pocket for specific cases:
Complex local state (canvas editors, design tools). If your app manages a 10,000-node graph where every drag fires 50 state mutations per second, you cannot round-trip to a server. Use Zustand or Jotai for the local editing session, then flush the final state to the server when the user saves.
Offline-first applications. A PWA that works without connectivity needs a local cache with conflict resolution. Server Actions assume network availability. Pair them with a local-first library like Automerge or Dexie for true offline support.
Cross-tab state synchronization. Server Actions run per-tab. If two tabs need to share ephemeral state (like a selected filter), use the BroadcastChannel API or a lightweight Zustand store with persist middleware.
Third-party SDK integrations. Stripe Elements, Mapbox, or video SDKs manage their own internal state. You cannot push that state through Server Actions. Keep these in client components with local state, and only submit the final result to the server.
A Practical Decision Heuristic
When evaluating whether a piece of state belongs on the server or client, ask:
- Does this data outlive the current session? If yes, it belongs on the server. User profiles, orders, comments, settings.
- Is this data needed by other users? If yes, it belongs on the server. Multiplayer state, shared documents, leaderboards.
- Does this data survive a page refresh? If it does but isn't shared with others,
localStorageorURLSearchParamsare simpler than Zustand. Filters, pagination cursor, draft form values. - Can this state be derived from server data? Derived state (filtered lists, computed totals) should never be stored separately. Compute it from the server response with
useMemo.
If a piece of state fails all four questions — it's ephemeral, single-user, session-only, and not derivable — keep it in local component state. If you find yourself passing it through five levels of props, reach for React Context before Zustand.
For everything else — form submissions, list mutations, auth flows, content management — Server Actions eliminate an entire class of bugs. The code is shorter, the data flow is linear, and the only source of truth is the database.
What This Means for Your Stack
If you're starting a new Next.js app in 2026:
| Concern | Old stack | New stack |
|---|---|---|
| Server data fetching | React Query + useQuery |
Server Component async + fetch |
| Mutations | React Query useMutation + cache invalidation |
'use server' actions + revalidatePath |
| Optimistic UI | onMutate rollback + manual cache update |
useOptimistic hook |
| Client state | Zustand / Jotai / Redux | Local component state or useActionState |
Drop React Query unless you need client-side polling or paginated query caching that spans routes. Drop Zustand unless you have a multi-tab offline editor. The framework handles the common case.
The hard part of state management was never about choosing Redux vs. Zustand. It was about deciding where state lives and how to keep copies consistent. React 19 answers that question: let the server own the data, and let the framework handle the sync.