The global store era is over. For the better part of a decade, Redux was the default answer to every state question, and developers learned to force every category of data into a single paradigm. Actions, reducers, dispatched thunks — it worked, but at a cost. Boilerplate, indirection, and a persistent mismatch between the tool and the data it was managing.
In 2026, state management looks radically different. React Server Components, streaming, and a new generation of purpose-built libraries have made the one-size-fits-all store obsolete. State now follows a simple principle: put it where it belongs, not where your reducer lives.
The Four Categories of State
Every piece of data in a modern web application falls into one of four categories:
| Category | Examples | Persistence | Scope |
|---|---|---|---|
| Server State | Database records, API responses | Server + cache | Global, shared |
| URL State | Search params, filters, page number | URL bar | Shareable, bookmarkable |
| Local State | Form inputs, toggle open/close, hover | None (in-memory) | Component tree |
| App State | Auth user, theme, websocket connection | localStorage / memory | Truly global |
Each category demands a different tool, a different caching strategy, and a different mental model. Mixing them is the primary source of state management complexity in modern apps.
Server State: Let the Server Own the Truth
Server state is data that lives on the server and is fetched by the client. Posts, comments, user profiles, product listings — the overwhelming majority of data in a typical web application. The client is never the source of truth; it is a cache.
In the Redux era, developers would fetch data in a thunk, dispatch a SET_POSTS action, store the result in a reducer, and write selectors to read it. This approach duplicated the server's data model on the client and forced manual cache invalidation.
React Query (TanStack Query)
The dominant pattern in 2026 is a dedicated server-state library. React Query handles fetching, caching, background revalidation, optimistic updates, and stale-while-revalidate logic out of the box.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: () => api.getPosts(),
staleTime: 1000 * 60 * 5, // Consider fresh for 5 minutes
gcTime: 1000 * 60 * 30, // Keep in cache for 30 minutes
refetchOnWindowFocus: true,
})
}
function useCreatePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: NewPost) => api.createPost(data),
onSuccess: () => {
// Invalidate the posts cache — triggers refetch
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
optimisticUpdate: async (newPost) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['posts'] })
// Snapshot previous data
const previous = queryClient.getQueryData(['posts'])
// Optimistically update
queryClient.setQueryData(['posts'], (old) => [...old, newPost])
return { previous }
},
})
}
SWR
SWR (stale-while-revalidate) is a lighter alternative from the Vercel ecosystem. It has a smaller API surface and integrates naturally with Next.js patterns.
import useSWR from 'swr'
import useSWRMutation from 'swr/mutation'
const fetcher = (url: string) => fetch(url).then(r => r.json())
function useUser(id: string) {
const { data, error, isLoading, mutate } = useSWR(
id ? `/api/users/${id}` : null, // null prevents fetching
fetcher,
{ revalidateOnFocus: true, dedupingInterval: 5000 }
)
return { user: data, error, isLoading, mutate }
}
Apollo Client
For GraphQL backends, Apollo remains the gold standard. Its normalized cache is uniquely powerful for GraphQL because it can update every query that references a changed entity without manual invalidation.
Comparison
| Feature | React Query | SWR | Apollo Client |
|---|---|---|---|
| Bundle size | ~13 KB | ~4 KB | ~35 KB |
| Cache normalization | Key-based | Key-based | Normalized (entity) |
| Optimistic updates | First-class | Via mutate | First-class |
| GraphQL support | Manual | Manual | Native |
| Pagination | Infinite, offset, cursor | Infinite, offset | Relay-style |
| Devtools | Excellent | Good | Excellent |
| Best for | REST APIs | Lightweight Next.js | GraphQL backends |
Server Components Change Everything
In 2026, React Server Components (RSC) have fundamentally changed the server-state landscape. Data fetching moves to the server, eliminating the need for client-side caching in many cases.
// app/posts/page.tsx — Server Component
async function PostsPage() {
const posts = await db.posts.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
})
return (
<div>
{posts.map(post => <PostCard key={post.id} post={post} />)}
</div>
)
}
This pattern removes the need for React Query entirely for initial page load. The data is fetched on the server, rendered into HTML, and streamed to the client. No loading state, no waterfall, no cache to manage.
Where client-side caching still matters is for user interactions that fetch new data without a full page navigation: search autocomplete, infinite scroll, paginated lists with client-side transitions, and mutation refetching. The hybrid pattern looks like this:
// app/posts/page.tsx — Server component for initial data
async function PostsPage({ searchParams }: { searchParams: { page?: string } }) {
const initialPosts = await getPosts({ page: Number(searchParams.page) || 1 })
return (
<div>
<ClientPostsList initialPosts={initialPosts} />
</div>
)
}
// Client component for interactive pagination
function ClientPostsList({ initialPosts }: { initialPosts: Post[] }) {
const [page, setPage] = useState(1)
const { data } = useQuery({
queryKey: ['posts', page],
queryFn: () => getPosts({ page }),
initialData: page === 1 ? initialPosts : undefined,
})
return <PostsView posts={data} onPageChange={setPage} />
}
Server state belongs on the server for initial render and in a thin cache layer for client interactions. If you are still putting API responses into a Redux store for every page, you are adding complexity without benefit.
URL State: The Most Underrated State Container
URL state encompasses search params, hash fragments, and path segments that represent application state. Filters, pagination, search queries, selected tabs, and sort order all belong in the URL.
The advantages are compelling:
- Survives page refreshes
- Shareable via link
- Works with browser back/forward
- Indexable by search engines (when rendered server-side)
Next.js useSearchParams
'use client'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
function useFilters() {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const filters = {
category: searchParams.get('category') || 'all',
sort: searchParams.get('sort') || 'newest',
page: Number(searchParams.get('page')) || 1,
}
const setFilter = (key: string, value: string | number) => {
const params = new URLSearchParams(searchParams.toString())
params.set(key, String(value))
if (key !== 'page') params.set('page', '1') // Reset page on filter change
router.push(`${pathname}?${params.toString()}`, { scroll: false })
}
return { filters, setFilter }
}
Nuxt 3 useRoute
The same pattern exists across frameworks. Nuxt 3 provides useRoute() and navigateTo() for URL state management.
When Not to Use URL State
URL state is not appropriate for:
- Large datasets (it would make URLs unshareable and hit length limits)
- Frequent updates (typing a search query character by character would flood the browser history)
- Sensitive data (API keys, tokens, PII)
For search inputs, use local state with URL sync on debounce:
const [search, setSearch] = useState(searchParams.get('q') || '')
const debouncedSearch = useDebounce(search, 400)
useEffect(() => {
const params = new URLSearchParams(searchParams.toString())
if (debouncedSearch) {
params.set('q', debouncedSearch)
} else {
params.delete('q')
}
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
}, [debouncedSearch])
Local State: The Beauty of useState
Local state is component-specific and transient. A dropdown open/close toggle, an accordion's expanded section, a form input's current value — these things do not need Redux, Context, or any library.
useState and useReducer
function Accordion({ items }: { items: AccordionItem[] }) {
const [openIndex, setOpenIndex] = useState<number | null>(null)
return (
<div>
{items.map((item, i) => (
<AccordionPanel
key={i}
isOpen={openIndex === i}
onToggle={() => setOpenIndex(openIndex === i ? null : i)}
{...item}
/>
))}
</div>
)
}
When Local State Escapes
Local state becomes problematic when it needs to be shared across distant components (prop drilling) or when it involves complex update logic. In those cases, consider:
- Composition — Restructure the component tree so shared state lives at the right level
- Lifting state — Move the state to the nearest common ancestor
- Context (sparingly) — For genuinely shared state that is read by many components at different levels
// Fine for theme, locale, auth — NOT for server data or form state
const ThemeContext = createContext<ThemeContextType>({
theme: 'light',
toggleTheme: () => {},
})
App State: The Only Case for a Global Store
Truly global state is rare. It includes:
- Authenticated user
- Theme preference
- Locale / language
- WebSocket connection status
- Feature flags
These are the only things that belong in a global store in 2026. And even then, the store should be tiny.
Zustand vs. Jotai vs. Context
For this slimmed-down global state, three tools dominate:
| Tool | Paradigm | Bundle | Best For |
|---|---|---|---|
| Zustand | Centralized store | ~2 KB | Simple global state with actions |
| Jotai | Atomic atoms | ~3 KB | Granular subscriptions, derived state |
| React Context | Built-in | 0 KB | Infrequently updated global values |
Zustand Example
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type Theme = 'light' | 'dark'
interface AppState {
theme: Theme
user: User | null
setTheme: (theme: Theme) => void
setUser: (user: User | null) => void
logout: () => void
}
export const useAppStore = create<AppState>()(
persist(
(set) => ({
theme: 'light',
user: null,
setTheme: (theme) => set({ theme }),
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
}),
{ name: 'app-store', partialize: (state) => ({ theme: state.theme }) }
)
)
Jotai Example
import { atom, useAtom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
const themeAtom = atomWithStorage<Theme>('theme', 'light')
const userAtom = atom<User | null>(null)
const isAuthenticatedAtom = atom((get) => get(userAtom) !== null)
function ThemeToggle() {
const [theme, setTheme] = useAtom(themeAtom)
return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle theme
</button>
}
Migration Patterns from Redux
If you are maintaining a Redux codebase and considering a migration, here is a proven approach:
- Identify state categories. Audit every slice of your Redux store. Classify each as server state, URL state, local state, or app state.
- Server state first. Replace data-fetching thunks with React Query or SWR. This typically removes 60-70% of Redux code.
- URL state migration. Move filters, pagination, and search to URL search params. This removes another 10-15%.
- Local state simplification. Components that were connected to Redux just to read one value from a server response can use the query library directly.
- Remaining app state to Zustand. Whatever is left — auth, theme, UI preferences — goes into a tiny Zustand store.
// Before: Redux
const dispatch = useDispatch()
const posts = useSelector((state) => state.posts.items)
const loading = useSelector((state) => state.posts.loading)
useEffect(() => { dispatch(fetchPosts()) }, [])
// After: React Query
const { data: posts, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: () => api.getPosts(),
})
The migration can be incremental. Redux and React Query can coexist in the same codebase. Migrate one slice at a time.
Performance Considerations
State management choices directly impact performance. Here are the key considerations in 2026:
Subscription Granularity
Zustand and Jotai use fine-grained subscriptions. When a value changes, only components that use that value re-render. React Context re-renders every consumer when any value changes.
// Context — re-renders ALL consumers on any change
const UserProvider = ({ children }) => {
const [user, setUser] = useState(null)
// Every consumer re-renders when setUser is called
}
// Zustand — re-renders only components that use userAtom
function Avatar() {
const user = useAppStore((state) => state.user)
// Only re-renders when user changes, not when theme changes
}
Server Component Data Flow
With React Server Components, the data fetching layer moves entirely to the server. This eliminates client-side waterfalls and reduces JavaScript bundle size because query logic is never shipped to the browser.
// ❌ Client component with waterfall
function PostPage() {
const { data: post } = useQuery(...) // Request 1
const { data: author } = useQuery(...) // Request 2 (after 1)
const { data: comments } = useQuery(...) // Request 3 (after 2)
}
// ✅ Server component with parallel data
async function PostPage({ id }) {
const [post, author, comments] = await Promise.all([
db.posts.findUnique({ where: { id } }),
db.users.findUnique({ where: { id: post.authorId } }),
db.comments.findMany({ where: { postId: id } }),
])
return <PostUI post={post} author={author} comments={comments} />
}
The 2026 State Management Stack
Here is what a WEIRDSOFT project typically ships with in 2026:
- Server state: React Query (REST) or Apollo Client (GraphQL), with Server Components for initial data
- URL state:
useSearchParams/useRouterin Next.js - Local state:
useState/useReducer - App state: Zustand (persisted to localStorage where needed)
- Forms: React Hook Form + Zod for validation
Total additional bundle size for state management: ~15 KB gzipped. A typical Redux setup replaced by this stack removes 30-40 KB from the bundle and eliminates hundreds of lines of boilerplate.
The principle is simple: each category of state gets the tool it deserves. Server state is not app state. URL state is not local state. Stop forcing everything into the same container. Your bundle size, your team velocity, and your users will all benefit.