WEIRDSOFT
Back to blog

Rethinking State Management in 2026

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:

  1. Composition — Restructure the component tree so shared state lives at the right level
  2. Lifting state — Move the state to the nearest common ancestor
  3. 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:

  1. Identify state categories. Audit every slice of your Redux store. Classify each as server state, URL state, local state, or app state.
  2. Server state first. Replace data-fetching thunks with React Query or SWR. This typically removes 60-70% of Redux code.
  3. URL state migration. Move filters, pagination, and search to URL search params. This removes another 10-15%.
  4. Local state simplification. Components that were connected to Redux just to read one value from a server response can use the query library directly.
  5. 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 / useRouter in 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.