Skip to main content

Guide

You Don't Need a State Management Library 2026

Most React apps don't need Redux or Zustand. Data on when React's built-in state, TanStack Query, and useContext handle 80% of use cases — and when libraries.

·PkgPulse Team·
0

TL;DR

Most apps don't need a state management library. React's built-in tools — useState, useReducer, useContext, and React Query for server state — handle 80% of real-world use cases. The "I need global state" problem is usually a "I need server state caching" problem (solved by TanStack Query) or a "my component tree is too deep" problem (solved by lifting state or restructuring). Add Zustand when you have genuinely global client state that multiple unrelated parts of the app need to share synchronously.

Key Takeaways

  • useState + useReducer covers most component-level state
  • TanStack Query handles server state (API responses, loading, caching) — this covers ~60% of "why I needed Redux"
  • useContext covers shared state between nearby components
  • Zustand is appropriate when you have truly global synchronous client state
  • Redux Toolkit makes sense for large teams who need predictable state + devtools

The State Management Trap

How developers fall into it:

1. Build a small React app
2. Need to share data between two components
3. Research "React state management"
4. Find Redux, Zustand, MobX, Recoil, Jotai, XState...
5. Add Redux Toolkit to a 3-component app
6. Write actions, reducers, selectors, thunks for a todo list
7. Hate the experience, blame Redux
8. Never question whether you needed Redux at all

The real question: What KIND of state are you managing?

Server state (API data, DB results, user data):
→ YOU DON'T NEED A STATE LIBRARY
→ You need TanStack Query (formerly React Query)

Local UI state (open/closed, active tab, form values):
→ YOU DON'T NEED A STATE LIBRARY
→ useState inside the component that owns it

Shared UI state (3-4 components need it):
→ YOU PROBABLY DON'T NEED A STATE LIBRARY
→ Lift state to common parent + prop drilling OR useContext

Global client state (auth token, theme, user preferences):
→ NOW you MIGHT need a state library
→ But even here: useContext + useReducer handles it for most apps

Global synchronized state (multiple tabs, real-time, undo/redo):
→ YES — this is the real use case for state libraries

What Built-in React Handles Well

// Case 1: Local component state
// useState for simple values, useReducer for complex state objects

// Simple: useState
function SearchBar() {
  const [query, setQuery] = useState('');
  const [isOpen, setIsOpen] = useState(false);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

// Complex: useReducer
type CartState = {
  items: CartItem[];
  total: number;
  coupon: string | null;
};

type CartAction =
  | { type: 'ADD_ITEM'; item: CartItem }
  | { type: 'REMOVE_ITEM'; id: string }
  | { type: 'APPLY_COUPON'; code: string };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.item],
        total: state.total + action.item.price,
      };
    // ...
  }
}

// No state library needed. This is React.

TanStack Query Solves "Server State" — The Real Redux Use Case

// The #1 reason developers reach for Redux: API data management
// "I need to store the user data globally"
// "I need to cache API responses"
// "I need loading states"
// "I need error handling"
// "I need to refetch when the user returns to the tab"

// Redux approach (complex):
// actions/user.ts
export const fetchUser = createAsyncThunk('user/fetch', async (id: string) => {
  const data = await api.getUser(id);
  return data;
});

// reducers/user.ts
const userSlice = createSlice({
  name: 'user',
  initialState: { data: null, loading: false, error: null },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.loading = true; })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.data = action.payload;
        state.loading = false;
      });
  },
});

// TanStack Query approach (simple):
import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => api.getUser(userId),
    staleTime: 5 * 60 * 1000,  // Cache for 5 minutes
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage />;

  return <div>{user.name}</div>;
}

// TanStack Query also gives you for FREE:
// → Automatic refetch on window focus
// → Deduplication (10 components requesting same data = 1 request)
// → Background refresh
// → Optimistic updates
// → Pagination and infinite scroll
// → DevTools
// → No boilerplate

useContext: The Underused Native Solution

// For state shared between 3-10 components in the same subtree:
// useContext is sufficient and ships with React

// theme.tsx
const ThemeContext = createContext<{
  theme: 'light' | 'dark';
  toggle: () => void;
}>({ theme: 'light', toggle: () => {} });

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  return (
    <ThemeContext.Provider
      value={{ theme, toggle: () => setTheme(t => t === 'light' ? 'dark' : 'light') }}
    >
      {children}
    </ThemeContext.Provider>
  );
}

export const useTheme = () => useContext(ThemeContext);

// Usage — any component can access this:
function Button() {
  const { theme, toggle } = useTheme();
  return <button onClick={toggle}>{theme === 'light' ? '🌙' : '☀️'}</button>;
}

// When useContext IS enough:
// → Auth state (currentUser, login, logout)
// → Theme (light/dark mode)
// → User preferences
// → Feature flags
// → Small apps with a single global state object

// When useContext ISN'T enough:
// → Frequent updates (re-renders all consumers on EVERY change)
// → Complex state with many independent slices
// → State that's unrelated to component tree structure

When to Actually Add Zustand

// Zustand is the right answer when:
// 1. You have global client state NOT from the server
// 2. Multiple UNRELATED parts of the app need the same state
// 3. You're having performance issues with useContext re-renders
// 4. You need state that persists across route changes

// Real examples where Zustand makes sense:
// - Shopping cart (not server data, persists across routes, everywhere in app)
// - Multi-step wizard state (shared between unrelated form steps)
// - WebSocket connection state (realtime data throughout app)
// - User-built workspace/canvas state (complex, frequent updates)

// What Zustand looks like:
import { create } from 'zustand';

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
}

const useCartStore = create<CartStore>((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) => set((state) => ({
    items: state.items.filter(i => i.id !== id),
  })),
  clearCart: () => set({ items: [] }),
}));

// Any component, anywhere:
function CartIcon() {
  const items = useCartStore((state) => state.items);
  return <span>{items.length}</span>;  // Only re-renders when items changes
}

function ProductPage({ productId }: { productId: string }) {
  const addItem = useCartStore((state) => state.addItem);
  return <button onClick={() => addItem({ id: productId, ... })}>Add to Cart</button>;
}

// Note: ProductPage doesn't re-render when items changes — only subscribed to addItem

The Redux Case: When It Actually Makes Sense

Redux Toolkit is appropriate when:

1. Large team with many developers touching shared state
   → Enforced patterns prevent "who broke global state" mysteries
   → Predictable state makes debugging easier across teams

2. Complex state with derived data and selectors
   → Redux Toolkit + createEntityAdapter for normalized CRUD state
   → Reselect for memoized derived state

3. Time-travel debugging is genuinely useful
   → Redux DevTools time-travel: replay actions to reproduce bugs
   → Valuable for complex user interaction debugging

4. You have the boilerplate budget
   → Redux has more setup than Zustand
   → Worth it when team size justifies the conventions

Real-world Redux use cases:
→ Figma-like collaborative editors (complex state, undo/redo)
→ Financial trading platforms (predictable state transitions matter)
→ Large enterprise apps with 10+ developers on the same state

What Redux is NOT appropriate for:
→ Todo apps, portfolios, blogs, marketing sites, landing pages
→ Apps with <5 developers
→ Apps where most data comes from APIs (TanStack Query handles this)

The Decision Tree

What state problem do you have?

"I need to fetch and cache API data globally"
→ TanStack Query (not a state library — it's data fetching)

"Two sibling components need shared state"
→ Lift to parent, prop drilling is fine for 2-3 levels

"5+ components across different levels need the same auth state"
→ useContext + useReducer

"Cart state needs to persist across routes and be in header AND checkout"
→ Zustand (genuinely global, not server state)

"My app has 50K lines, 10 developers, complex UX workflows"
→ Redux Toolkit

"I'm building a collaborative real-time tool"
→ Zustand + Yjs, or XState for complex state machines

If you're unsure which bucket you're in:
→ You're in the TanStack Query bucket.
→ 60% of "I need global state" problems are server state.
→ Install TanStack Query first. Solve 80% of the problem.
→ Add Zustand if you still have global client state needs.
→ Add Redux Toolkit if you're on a large team with complex patterns.

The progression:
1. useState / useReducer (always start here)
2. TanStack Query (if it's server state)
3. useContext (if it's shared between nearby components)
4. Zustand (if it's truly global client state)
5. Redux Toolkit (if you're on a large team)

Most apps stop at step 2 or 3.

The Performance Cost of Over-Engineered State

Adding a state management library carries concrete performance costs that are easy to underestimate before you're dealing with them in production. At the bundle level, every library adds weight: Redux Toolkit ships around 11KB minified and gzipped, Zustand around 1.8KB, Jotai around 3KB. For many apps, this is a rounding error — but it compounds with the rest of your dependency tree.

The more significant cost is re-render behavior. Every component that subscribes to a global store re-renders when that store's state changes, regardless of whether the component uses the specific piece of state that changed. This is the over-subscription problem. Connect 50 components to a Redux store, dispatch a single action that updates one field, and you may see 10–15 components re-render — even if only 2 or 3 of them display data that changed. Redux's useSelector mitigates this with shallow equality checks on the selected slice, but it requires developers to write selectors carefully. In practice, many codebases have components selecting too broadly, causing cascading renders on every dispatch.

The React DevTools profiler makes this visible. Record a session, dispatch a few actions, and look at how many components highlighted. Any component that flashes on a dispatch it doesn't visually respond to is an over-subscriber.

The alternative doesn't require a library. Colocate state with the components that own it. A counter component that only affects its own UI should use useState — not a global store slice. A form that doesn't share its values with any other component should manage its own state locally. Only promote state to global scope when multiple unrelated components genuinely need to read or write the same value synchronously.

The practical test before reaching for global state: if you removed this state from the global store and moved it into the component, would any other component break? If the answer is no, it doesn't belong in the global store. Most state fails this test.


The TanStack Query Test: Is Your "State" Actually Server Data?

The widespread adoption of TanStack Query (formerly React Query) revealed something uncomfortable about how most Redux codebases had been structured: the majority of what lived in the Redux store was server state — not client state at all.

The distinction is important. Server state is data that originates from an API: user profiles, product listings, dashboard metrics, search results. It has a canonical source of truth on the server, needs to be fetched, cached, potentially refetched in the background, and invalidated when it becomes stale. Client state is UI-specific: which tab is currently active, whether a modal is open, what text has been typed into a search field before submission. Client state lives only in the browser. No API owns it.

The historical problem: before TanStack Query existed, developers had no good caching layer for API data in React applications. So they stored API responses in Redux. The Redux store became a makeshift cache for server data AND a container for client UI state at the same time — the worst of both worlds. Server state stored in Redux doesn't automatically refetch when it goes stale, doesn't deduplicate concurrent requests, and requires manual loading/error state management for every endpoint.

TanStack Query was built specifically for server state and handles everything Redux couldn't: automatic background refetching on window focus, request deduplication across 10 concurrent components requesting the same data, cache invalidation, optimistic updates, pagination, and built-in devtools. None of that has to be written by hand.

The takeaway is practical: install TanStack Query before reaching for Zustand or Redux. Migrate your API data out of the Redux store. After that migration, look at what remains. In most applications, what's left is "which tab is active" and "is this modal open" — state that useState handles in three lines. At that point, you probably don't need Zustand either.


URL as State: The Overlooked Architecture

One of the most underused state management strategies in React applications is the URL itself. The URL bar is a persistent, shareable, bookmarkable state container that is available in every web application without installing anything. Filters, pagination, search queries, selected items, sort order, active tabs, modal state — all of these are candidates for URL-based state, and putting them there instead of in a global store or component state produces a meaningfully better user experience at zero additional library cost.

The practical case for URL state becomes clear when you consider what users lose when state lives only in memory. A user who filters a product list by category, sort order, and price range and then shares the URL with a colleague gets a link that opens an unfiltered list. A user who navigates away from a paginated table and back loses their place. A user who hard-refreshes a page in the middle of a multi-step workflow lands at the beginning. All of these are solvable by moving the relevant state into the URL, and they're problems that no global state library addresses — because the state evaporates on navigation or refresh regardless of where it lives in memory.

React Router v6 and Next.js's useSearchParams both make URL state management straightforward. Reading a search parameter is one line; updating it is a router navigation call that doesn't cause a page reload. The pattern is composable: a custom hook wrapping useSearchParams can encapsulate the serialization and deserialization of complex filter state into a clean, typed API that components consume exactly like they would a Zustand store — except the state survives refreshes, is shareable by URL, and is handled by the browser's back and forward buttons for free.

The tradeoff to manage is URL length and readability. State that updates at sub-100ms frequency — mouse position, scroll position, animation keyframes — is not appropriate for URL synchronization. State that updates on explicit user interaction (applying a filter, changing a sort order, navigating to a page) is the sweet spot. The heuristic is: if a user would want to bookmark or share this UI state, it belongs in the URL. If it's transient interaction state they'd never think to preserve, it belongs in component state.

React Server Components and How They Change the State Calculus

React Server Components (RSC), stable in Next.js since the App Router in 2023, change the tradeoffs around state management in ways that the ecosystem is still absorbing. The core shift is that a significant category of "state" — the data fetched from APIs and databases to render UI — can now be handled on the server rather than the client, eliminating the need for client-side state management of that data entirely.

In the traditional React model (client-side rendering or client-side data fetching), every piece of server data that needs to display in the UI must travel through a fetch call on the client, live in some state container (whether useState, Redux, or TanStack Query's cache), and then be rendered. TanStack Query exists largely to make this pattern ergonomic — handling caching, deduplication, and background refetching of data that fundamentally originated on the server.

With Server Components, data fetching moves into the server render itself. A component that needs user data, product data, or dashboard metrics fetches it directly in the server component function — no client-side state, no loading spinner, no cache invalidation logic, no TanStack Query integration required. The data arrives pre-rendered as HTML. Client components that need to be interactive receive only the props they need, not the full data payload. The result is that a meaningful portion of what previously required TanStack Query (and before that, Redux) is simply not a client state problem anymore.

The practical implication for state management decisions: when starting a new Next.js App Router project, the correct default is to handle as much data as possible in server components, use URL state for user-controlled filters and navigation, and reach for useState or useContext for the UI state that remains on the client. TanStack Query still earns its place for cases that require client-side data synchronization — real-time updates, optimistic mutations, client-triggered refetches — but the scope of that category has narrowed. Zustand and Redux's scope narrows further: genuinely global synchronous client state that isn't server data and doesn't belong in the URL is a smaller fraction of application state than it was in 2020.

This is not a claim that TanStack Query or Zustand are obsolete — they solve real problems and do it well. It's a claim that the default starting point has shifted. In 2019, the recommendation was to add Redux early. In 2022, the recommendation shifted to TanStack Query first, then Zustand if needed. In 2026, with RSC, the recommendation is server components for data, URL for user-navigable state, and TanStack Query or Zustand only for the specific problems that require them. The progression toward doing less on the client is consistent, and each step reduces the scope of state management overhead.

The Actual Use Cases Where Zustand Earns Its Place

Zustand has become the most widely adopted lightweight state management library in the React ecosystem, and its success reflects a genuine product-market fit: it solves the specific problem of cross-cutting synchronous global state with almost no boilerplate. Understanding where it fits requires being precise about what "cross-cutting synchronous global state" actually means in practice, because the phrase is easy to abuse as a justification for putting everything in a store.

The canonical Zustand use cases are a small set of patterns that repeat across different applications. Authentication and session state is the most universal: the current user's identity, their permissions and role, whether they're authenticated, and the session token all need to be readable from anywhere in the application — the header, protected routes, API call wrappers, feature flag evaluations. This state is updated infrequently (on login and logout) but read everywhere. It doesn't come from an API in the sense that TanStack Query handles — it's set once and consumed broadly. Zustand is appropriate here.

Notification and toast state is a second classic case. A notification queue that can be pushed to by any component in the application and consumed by a single notification renderer at the root level is exactly the pattern Zustand handles well. The queue needs to be globally writable (any action in the app can trigger a notification) and globally readable (the renderer needs to display whatever's in the queue). Neither useContext nor TanStack Query is a natural fit; a Zustand store is idiomatic.

Theme and user preference state — dark mode, display density, sidebar collapsed state — fits Zustand because it needs to survive route navigation, be readable by many unrelated components, and often be persisted to localStorage. Zustand's persist middleware handles the localStorage integration in a few lines.

The misuse pattern that appears most often in codebases that have over-adopted Zustand is storing things in the global store that have a clear owner component. A form's draft state. A modal's content. A component's currently-hovered item. These have a natural home in component state — the component that renders the form, the modal, the list. Promoting them to global state because "we use Zustand for state" creates a store with dozens of loosely related slices, where changes to one slice can accidentally affect unrelated components, and where the distinction between what's global and what's local has been abandoned. The result looks like the original Redux bloat problem — just with less boilerplate.

Local Component State and the Discipline of Keeping It Local

The most important architectural decision in React state management is not which library to use — it is where each piece of state lives, and whether that location is the most local possible. State should live at the lowest level of the component tree that can satisfy all the components that need it. Pushing state higher than necessary creates unnecessary coupling; pulling it lower than necessary creates prop-drilling. The goal is finding the natural owner.

This discipline has a name in React documentation: "lifting state up." But the pattern works in both directions. State should be lifted to the nearest common ancestor of the components that need it, and no higher. A shopping cart total that's displayed in both the page header and the checkout summary needs to live at a level that's an ancestor of both — but not necessarily at the application root. If those two components share a layout component, the cart state can live there. If they're genuinely in separate parts of the tree with no shared ancestor below the application root, then and only then does it belong in global state.

The practical discipline is to start with state in the component where it's first created, then move it up the tree only when a child component needs to read or write it. Each time you lift state, ask whether there's a way to avoid the lift by restructuring components so the state consumer and state owner are closer together. Sometimes the lift is necessary and unavoidable. Often, restructuring the component tree — extracting a compound component, co-locating a feature's components — keeps state local without loss of functionality.

This matters for performance as well as architecture. State that lives in a component re-renders only that component and its children when it changes. State that lives in a global store re-renders every component subscribed to that slice of the store. For state that updates frequently — text input values, hover states, animation state — local component state is always the right choice. The re-render scope is minimal by construction, requiring no selector optimization or subscription management.

The bottom line is that the discipline of keeping state local is more valuable than choosing the right global state library. A codebase that uses React's built-in state correctly, with state owned by the right component at the right level of the tree, needs significantly less global state management than one that reflexively promotes state upward. The library choice matters at the margins — for the state that genuinely needs to be global. The discipline matters everywhere.


Compare Zustand vs Redux download trends and health scores at PkgPulse.

See also: React vs Vue and React vs Svelte, MobX vs Valtio vs Legend-State: Observable State 2026.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.