Vikram D.

State Management Architecture: From Context to External Stores

2026-01-0715 min read
State Management Architecture: From Context to External Stores

State management is one of the most debated topics in React. Every few months, a new library trends, and developers wonder: "Should I switch?"

The truth is, there's no single best solution. The right choice depends on what kind of state you're managing and how your application is structured.

In this post, we'll cover:

  1. The two types of state: client vs server
  2. React's built-in tools: useState, useReducer, Context
  3. When Context breaks down
  4. External stores: Zustand, Jotai, Redux Toolkit
  5. Server state: TanStack Query and SWR
  6. A decision framework for choosing the right tool

1. Client State vs Server State

Before choosing a library, understand what kind of state you're dealing with.

Client State

State that exists only in the browser and is owned by your UI:

  • Form inputs and validation
  • Modal open/closed
  • Selected tab
  • Dark mode preference
  • UI filters and sorting
  • Drag-and-drop positions

Characteristics:

  • Synchronous
  • Ephemeral (lost on refresh unless persisted)
  • You control the source of truth

Server State

State that originates from a remote source:

  • User profile from /api/user
  • Product list from /api/products
  • Comments, notifications, search results

Characteristics:

  • Asynchronous
  • Has a canonical source of truth (the server)
  • Can become stale
  • Needs caching, refetching, and synchronization
  • Multiple components might need the same data

Interview Question: What's the difference between client state and server state?

Answer: Client state is UI-owned data that exists only in the browser (form inputs, modals, preferences). Server state originates from a remote API and needs caching, refetching, and synchronization. The key insight is they require different tools: client state needs reactive stores, while server state needs cache management with features like background refetching and optimistic updates.


2. React's Built-In Tools

Before reaching for external libraries, understand what React provides out of the box.

useState: Local Component State

The simplest option for state that belongs to a single component:

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

Use when:

  • State is local to one component
  • No need to share with siblings or distant components

useReducer: Complex Local State

When state has multiple sub-values or complex update logic:

type State = { count: number; step: number };
type Action = 
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'setStep'; payload: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + state.step };
    case 'decrement':
      return { ...state, count: state.count - state.step };
    case 'setStep':
      return { ...state, step: action.payload };
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
  
  return (
    <div>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <span>{state.count}</span>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
    </div>
  );
}

Use when:

  • State has multiple related values
  • Next state depends on previous state in complex ways
  • You want predictable state transitions

Context: Sharing State Down the Tree

React Context lets you pass data through the component tree without prop drilling:

// 1. Create context
const ThemeContext = createContext<'light' | 'dark'>('light');

// 2. Provide at the top
function App() {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  
  return (
    <ThemeContext.Provider value={theme}>
      <Header />
      <Main />
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
        Toggle Theme
      </button>
    </ThemeContext.Provider>
  );
}

// 3. Consume anywhere below
function Header() {
  const theme = useContext(ThemeContext);
  return <header className={theme}>...</header>;
}

Use when:

  • Data needs to be accessible by many components at different nesting levels
  • Data changes infrequently (theme, locale, auth)

3. When Context Breaks Down

Context is great for low-frequency, read-heavy data. But it has a critical limitation:

The Re-render Problem

When the context value changes, every component that consumes it re-renders.

const AppContext = createContext({ user: null, theme: 'light', notifications: [] });

function App() {
  const [state, setState] = useState({
    user: { name: 'John' },
    theme: 'light',
    notifications: []
  });
  
  return (
    <AppContext.Provider value={state}>
      <Header />      {/* Re-renders on ANY state change */}
      <Sidebar />     {/* Re-renders on ANY state change */}
      <MainContent /> {/* Re-renders on ANY state change */}
    </AppContext.Provider>
  );
}

If notifications updates frequently, Header and Sidebar re-render even if they only care about theme.

Common Workarounds (And Their Limits)

1. Split into multiple contexts:

<ThemeContext.Provider value={theme}>
  <UserContext.Provider value={user}>
    <NotificationsContext.Provider value={notifications}>
      {children}
    </NotificationsContext.Provider>
  </UserContext.Provider>
</ThemeContext.Provider>

Works, but gets unwieldy as state grows.

2. Memoize consumers:

const Header = memo(function Header() {
  const theme = useContext(ThemeContext);
  return <header className={theme}>...</header>;
});

Helps, but doesn't prevent the context subscription from triggering.


4. External Stores: Zustand, Jotai, Redux

When Context isn't enough, external state libraries offer better performance and developer experience.

4.1 Zustand: Simple and Flexible

Zustand is a minimalist store with a hooks-based API. No providers, no boilerplate.

import { create } from 'zustand';

interface Store {
  count: number;
  increment: () => void;
  decrement: () => void;
}

const useStore = create<Store>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));

// Component only re-renders when `count` changes
function Counter() {
  const count = useStore((state) => state.count);
  const increment = useStore((state) => state.increment);
  
  return <button onClick={increment}>Count: {count}</button>;
}

Key features:

  • No Provider needed (store lives outside React)
  • Built-in selectors for granular subscriptions
  • Middleware support (persist, devtools, immer)
  • Tiny bundle (~1KB)

Persisting to localStorage:

import { persist } from 'zustand/middleware';

const useStore = create(
  persist<Store>(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
    }),
    { name: 'counter-storage' }
  )
);

4.2 Jotai: Atomic State

Jotai takes an atomic approach — each piece of state is an independent "atom."

import { atom, useAtom } from 'jotai';

// Define atoms
const countAtom = atom(0);
const doubleCountAtom = atom((get) => get(countAtom) * 2);

// Use in components
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}

function DoubleDisplay() {
  const [double] = useAtom(doubleCountAtom);
  return <div>Double: {double}</div>;
}

Key features:

  • Bottom-up approach (atoms compose into derived state)
  • No selectors needed — atoms are already granular
  • Works well with Suspense and concurrent features
  • Great for derived/computed state

When to choose Jotai over Zustand:

  • You prefer atomic composition over centralized stores
  • Lots of derived state (computed values)
  • React Suspense integration is important

4.3 Redux Toolkit: The Enterprise Standard

Redux Toolkit (RTK) is the modern way to use Redux. It eliminates boilerplate and includes best practices.

import { configureStore, createSlice } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';

// Slice = reducer + actions bundled
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1 },
    decrement: (state) => { state.value -= 1 },
    incrementByAmount: (state, action) => { state.value += action.payload },
  },
});

const store = configureStore({
  reducer: { counter: counterSlice.reducer },
});

// Component
function Counter() {
  const count = useSelector((state) => state.counter.value);
  const dispatch = useDispatch();
  
  return (
    <button onClick={() => dispatch(counterSlice.actions.increment())}>
      Count: {count}
    </button>
  );
}

// App
function App() {
  return (
    <Provider store={store}>
      <Counter />
    </Provider>
  );
}

Key features:

  • Immer built-in (write mutating logic, get immutable updates)
  • Redux DevTools integration
  • RTK Query for data fetching
  • Type-safe with TypeScript
  • Large ecosystem and community

When to choose Redux Toolkit:

  • Large team with established Redux knowledge
  • Need robust DevTools and time-travel debugging
  • Complex async flows with thunks/sagas
  • Enterprise apps with strict patterns

Comparison Table

FeatureContextZustandJotaiRedux Toolkit
Bundle size0 (built-in)~1KB~2KB~10KB
BoilerplateLowVery LowLowMedium
Selectors✅ (atoms)
DevTools
MiddlewareLimited
Learning curveLowLowLowMedium
Best forTheme, localeUI stateAtomic stateLarge apps

5. Server State: TanStack Query and SWR

Client state libraries like Zustand are not designed for server state. Server state has unique challenges:

  • Caching
  • Background refetching
  • Stale data management
  • Optimistic updates
  • Pagination and infinite scroll
  • Request deduplication

TanStack Query (React Query)

The most popular solution for server state:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// Fetch data
function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
    staleTime: 5 * 60 * 1000, // Data is fresh for 5 minutes
  });
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error loading user</div>;
  
  return <div>{data.name}</div>;
}

// Mutate and invalidate
function UpdateUserButton({ userId }: { userId: string }) {
  const queryClient = useQueryClient();
  
  const mutation = useMutation({
    mutationFn: (newName: string) => 
      fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        body: JSON.stringify({ name: newName }),
      }),
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
  });
  
  return <button onClick={() => mutation.mutate('New Name')}>Update</button>;
}

Key features:

  • Automatic caching and deduplication
  • Background refetching
  • Stale-while-revalidate pattern
  • Optimistic updates
  • Infinite queries for pagination
  • Suspense support

SWR (Stale-While-Revalidate)

Vercel's alternative, simpler API:

import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then(r => r.json());

function UserProfile({ userId }: { userId: string }) {
  const { data, error, isLoading } = useSWR(`/api/users/${userId}`, fetcher);
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error loading user</div>;
  
  return <div>{data.name}</div>;
}

When to choose SWR:

  • Simpler needs (read-heavy, less mutation complexity)
  • Smaller bundle size
  • Vercel/Next.js ecosystem

When to choose TanStack Query:

  • Complex mutations with optimistic updates
  • Need fine-grained cache control
  • Infinite scroll / pagination
  • DevTools for debugging

6. Decision Framework

Here's how to think about state management:

Quick Decision Guide

ScenarioRecommendation
Data from APITanStack Query or SWR
Theme, locale, authReact Context
Complex formsReact Hook Form + Zod
Global UI state (modals, toasts)Zustand
Lots of computed/derived valuesJotai
Large team, complex flowsRedux Toolkit
Simple local stateuseState/useReducer

The Layered Approach

In practice, production apps often combine multiple solutions:

// Layer 1: Server state
const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });

// Layer 2: Global UI state (Zustand)
const theme = useUIStore(state => state.theme);
const openModal = useUIStore(state => state.openModal);

// Layer 3: Local component state
const [isExpanded, setIsExpanded] = useState(false);

// Layer 4: Form state
const { register, handleSubmit } = useForm();

7. Summary

State management isn't about picking the "best" library. It's about matching the right tool to the right problem.

State TypeCharacteristicsTools
LocalSingle component, simpleuseState, useReducer
Shared (low-frequency)Theme, auth, localeReact Context
Shared (high-frequency)UI state, real-timeZustand, Jotai
Complex globalLarge apps, DevTools neededRedux Toolkit
ServerAPI data, cachingTanStack Query, SWR
FormsValidation, submissionReact Hook Form

The best architectures use multiple layers:

  • TanStack Query for server state
  • Zustand or Context for global UI
  • Local state for component-specific concerns

Don't over-engineer. Start with the simplest solution that works, and add complexity only when you feel the pain.


What's your go-to state management setup? Have you tried combining TanStack Query with Zustand? Share your experience in the comments!

Loading comments...