Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 014 / 100

State management in 2026: a hands-on guide for engineers

There is no single state. There are four kinds.

Expona Admin8 min read

Introduction

Most React engineers get state management wrong—not because they lack skill, but because they try to solve four distinct problems with one tool. State management in 2026: a hands-on guide for engineers means recognizing that local UI state, server state, URL state, and global client state each demand different strategies. In this post, we'll walk through how to structure these four kinds of state in a modern Next.js application, what tools suit each, and when to resist the urge to centralize everything into Redux.

Four kinds of state: TYPE vs TOOL

Before reaching for a library, ask yourself: what problem am I solving? The answer almost always falls into one of four categories. Confusing these categories leads to bloated stores, stale caches, and components that don't survive a page refresh.

Local UI state

Local UI state lives in a single component or a small subtree. It controls whether a modal is open, whether a dropdown is expanded, which tab is active. It doesn't need to persist, doesn't need to sync across tabs, and doesn't need to outlive the component.

Tools: useState, useReducer

Local state should be your default. It's the fastest to write, easiest to reason about, and hardest to get wrong. Here's a typical pattern:

tsx
export function ProductFilters() {
  const [isOpen, setIsOpen] = useState(false);
  const [sortBy, setSortBy] = useState('relevance');

  return (
    <>
      <button onClick={() => setIsOpen(!isOpen)}>
        Filters
      </button>
      {isOpen && (
        <div>
          <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
            <option value="relevance">Most relevant</option>
            <option value="price-low">Price: low to high</option>
          </select>
        </div>
      )}
    </>
  );
}

Resist the temptation to hoist this into a global store. If you can't see the state and the component in the same file, you've over-engineered it.

Server state

Server state is any data that lives on your backend: user posts, product listings, database records. The catch: your client cache can drift from the source of truth. A user deletes a comment. Another user adds a review. Your local copy doesn't know.

Tools: TanStack Query, RTK Query, React Server Components

Server state needs a dedicated synchronization layer. Here's a typical flow with TanStack Query:

tsx
import { useQuery } from '@tanstack/react-query';

export function ProductList() {
  const { data: products, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      const res = await fetch('/api/products');
      return res.json();
    },
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

TanStack Query handles caching, background refetching, stale-while-revalidate, and request deduplication. When you add a new product, you call queryClient.invalidateQueries({ queryKey: ['products'] }) to refresh the cache. When you navigate away and back, TanStack Query can serve stale data instantly while fetching fresh data in the background.

If you're already using Next.js App Router, consider React Server Components (RSCs) instead. They fetch data at render time on the server, eliminating the need for client-side caching logic:

tsx
export default async function ProductList() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 } // Cache for 60 seconds
  });
  const products = await res.json();

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

URL state

URL state is criminally underrated. Search filters, pagination, sorting, the current step in a wizard—these belong in the URL, not in global state. Why? Because the URL is the user's bookmark. It's what they share. It's what survives a refresh.

Tools: Search params, route segments, useSearchParams, useRouter

Imagine a product listing with filters. Without URL state:

  • User filters by "electronics" and "under $50"
  • User refreshes the page
  • Filters disappear
  • User is confused

With URL state:

tsx
import { useSearchParams } from 'next/navigation';

export function ProductFilters() {
  const searchParams = useSearchParams();
  const router = useRouter();
  
  const category = searchParams.get('category') || '';
  const maxPrice = searchParams.get('maxPrice') || '';

  const handleFilter = (cat, price) => {
    const params = new URLSearchParams(searchParams);
    params.set('category', cat);
    params.set('maxPrice', price);
    router.push(`?${params.toString()}`);
  };

  return (
    <div>
      <input
        value={category}
        onChange={(e) => handleFilter(e.target.value, maxPrice)}
        placeholder="Category"
      />
      <input
        value={maxPrice}
        onChange={(e) => handleFilter(category, e.target.value)}
        placeholder="Max price"
      />
    </div>
  );
}

Now the URL is ?category=electronics&maxPrice=50. The user bookmarks it. They send it to a friend. They refresh the page and the filters stick. This is state management for free.

Global client state

Global state is for data that crosses multiple pages and doesn't change often: the logged-in user, theme preference, authentication tokens, a cart that persists across page navigation.

Tools: Zustand, Redux Toolkit, Context (for small datasets)

Keep it minimal. A bloated global store is a sign you've stopped thinking clearly about state categories. Here's a lean example with Zustand:

tsx
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export const useAppStore = create(
  persist(
    (set) => ({
      user: null,
      setUser: (user) => set({ user }),
      theme: 'light',
      setTheme: (theme) => set({ theme }),
      cartCount: 0,
      setCartCount: (count) => set({ cartCount: count }),
    }),
    {
      name: 'app-store',
    }
  )
);

And in a component:

tsx
export function Header() {
  const user = useAppStore((state) => state.user);
  const setTheme = useAppStore((state) => state.setTheme);
  const cartCount = useAppStore((state) => state.cartCount);

  return (
    <header>
      {user && <p>Welcome, {user.name}</p>}
      <button onClick={() => setTheme('dark')}>Toggle theme</button>
      <span>Cart: {cartCount}</span>
    </header>
  );
}

Zustand is small, fast, and doesn't require boilerplate. Redux Toolkit is heavier but offers time-travel debugging and middleware—useful in large teams or complex workflows.

Server state is not app state

This is the biggest mistake we see. Engineers treat server state like global state, dumping API responses into Redux or Context. Then they wonder why their app feels stale.

Server state is owned by the server. Your client holds a cache. The cache can expire, can be invalidated, can be refetched. It is not the source of truth.

When you use TanStack Query or RTK Query, you get:

  • Automatic deduplication: Two components request the same data within milliseconds—only one network request fires.
  • Background refetching: The user regains focus on the browser tab. Query silently refetches in the background.
  • Stale-while-revalidate: Serve cached data instantly, then fetch fresh data and update. Users feel speed without staleness.
  • Mutation coordination: When you post a comment, invalidate the comments query. Observers re-fetch automatically.

Without a dedicated server-state tool, you end up manually managing loading states, caches, and invalidation. That's where bugs breed.

URL state is underrated

Modern web apps often ignore the URL. They treat it as a static route and jam everything into global state. This breaks:

  • Browser back/forward buttons
  • Bookmarking
  • Sharing links
  • Page refresh recovery

A better pattern: keep transient UI state in the URL. In an e-commerce site:

  • Current page in pagination → ?page=3
  • Active filters → ?category=shoes&minPrice=50&maxPrice=200
  • Sort order → ?sort=price-asc
  • Search term → ?q=blue%20running%20shoes

When the component mounts, read from useSearchParams. When the user interacts, update the URL via router.push. The browser handles undo/redo for free.

Tools like TanStack Router go further, offering type-safe search params and nested route state. But even the built-in useSearchParams hook solves most cases.

Global state should be small

A healthy global store might contain:

  • Authenticated user object (name, email, role)
  • Theme preference (light/dark)
  • Sidebar visibility toggle
  • Language/locale setting
  • Notification queue (for toast messages)

A bloated global store often contains:

  • All product data from the last API call
  • Modal open/close state for 15 different modals
  • Form draft data for every page
  • Cached API responses that should be in TanStack Query

When your store grows beyond a few kilobytes, stop and ask: should this live in the URL? Should this be server state? Should this be local?

Common mistakes

  • Treating server state as app state: Fetching data once, storing it in Redux, and never refetching. Server data goes stale. Use TanStack Query.
  • Ignoring the URL: Putting filters, pagination, and sorting in global state instead of the URL. Users can't bookmark or share links.
  • Over-centralizing: Every piece of state in one global store because "it's easier." Result: a 100KB bundle, runtime slowdown, and merge conflicts on every feature branch.
  • Persisting everything: Saving all global state to localStorage without thinking about what needs to survive a session. Authentication tokens, session data, and preferences should persist. Transient UI flags should not.
  • Mixing concerns in a single hook: A hook that fetches data, transforms it, and manages UI state. Break it into smaller pieces. Use useQuery for fetching, useState for UI.

Key takeaways

  • State management in 2026 means recognizing four distinct categories: local UI state, server state, URL state, and global client state.
  • Local UI state lives in useState. Keep it close to where it's used.
  • Server state needs TanStack Query, RTK Query, or React Server Components—never a general-purpose store.
  • URL state is shareable and persistent. Filters, pagination, and sorting belong in search params.
  • Global state should be small: user, theme, preferences. Everything else is probably in the wrong category.
  • Choosing the right tool for each state type prevents over-engineering, reduces bundle size, and makes your app feel faster and more reliable.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles