Scaling State Management in React Without Killing Performance

As React applications grow, state management often becomes one of the first—and most painful—scaling problems. What starts as a few useState hooks quickly turns into deeply nested Context providers, bloated global stores, and hard-to-debug performance regressions.

This article walks through how to scale state management in React deliberately, with a strong focus on performance, maintainability, and memory usage. We'll clarify the role of dependency injection, explain why Context often gets misused, and show how modern tools like Zustand can simplify feature-scoped state management. Finally, we'll establish a clear decision framework for choosing the right tool for the right kind of state.

State management architecture diagram

The Core Problem: State Grows Faster Than Architecture

In small applications, almost any state management approach works. In large applications, everything breaks eventually—especially when state outlives its usefulness or is scoped too broadly.

Common symptoms include:

  • Unnecessary re-renders across the app
  • Memory usage growing over time
  • "Just in case" global state
  • Tight coupling between unrelated features
  • Teams afraid to refactor or delete state

The root cause is rarely React itself.

It's almost always misaligned state lifetime and ownership.

Understanding Dependency Injection in React

Before talking about state management, it's important to understand what React Context was actually designed for.

Context is fundamentally a dependency injection (DI) mechanism.

Dependency injection means:

  • Supplying dependencies to components
  • Without passing them explicitly through props
  • Where the dependency is read often but changes rarely

Typical examples:

  • Theme
  • Locale
  • Feature flags
  • Authentication configuration

These values are:

  • Stable
  • Global (or semi-global)
  • Expected to update infrequently

Context excels here because re-rendering all consumers is acceptable.

Example: Context Used Correctly

// ThemeContext.tsx
const ThemeContext = createContext<'light' | 'dark'>('light')

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')
  
  // Theme changes infrequently, so re-rendering all consumers is fine
  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  return useContext(ThemeContext)
}

Where Context Goes Wrong: State Management by Accident

Problems begin when Context is used to manage dynamic, frequently changing state.

Examples:

  • Filters on a list page
  • Table pagination and sorting
  • Selection state
  • Workflow or wizard state

In these cases:

  • The Context value changes often
  • Many components consume it
  • Every update re-renders all consumers

This is not a bug—it's how Context works.

Context has:

  • No selector mechanism
  • No partial subscriptions
  • No built-in performance guarantees

Using it this way turns dependency injection into accidental state management.

Example: Context Misused for Dynamic State

// ❌ BAD: Using Context for frequently changing state
const FilterContext = createContext<{
  filters: Record<string, string>
  setFilter: (key: string, value: string) => void
}>({ filters: {}, setFilter: () => {} })

export function FilterProvider({ children }: { children: React.ReactNode }) {
  const [filters, setFilters] = useState<Record<string, string>>({})
  
  const setFilter = useCallback((key: string, value: string) => {
    setFilters(prev => ({ ...prev, [key]: value }))
  }, [])
  
  // Every filter change re-renders ALL consumers
  return (
    <FilterContext.Provider value={{ filters, setFilter }}>
      {children}
    </FilterContext.Provider>
  )
}

// Component that only needs to read one filter
function ProductNameFilter() {
  const { filters } = useContext(FilterContext)
  // Re-renders even when other filters change!
  return <input value={filters.name || ''} />
}

The Problem: Every time any filter changes, ProductNameFilter re-renders, even though it only cares about the name filter.

"But We Can Split and Memoize Context"

This is true—and commonly done.

Teams try to fix Context performance issues by:

  • Splitting state across multiple contexts
  • Memoizing provider values
  • Creating custom hooks per concern

These techniques do work technically.

However, they introduce new problems:

  • High manual effort
  • Increased boilerplate
  • Provider nesting explosions
  • Fragile performance assumptions
  • Strong reliance on team discipline

At scale, this becomes difficult to maintain.

You end up rebuilding a state management library manually, without the safety rails.

This is usually the point where teams realize they need a better abstraction.

Example: The Memoization Workaround

// ⚠️ WORKS, but verbose and error-prone
const FilterContext = createContext<{
  filters: Record<string, string>
  setFilter: (key: string, value: string) => void
}>({ filters: {}, setFilter: () => {} })

export function FilterProvider({ children }: { children: React.ReactNode }) {
  const [filters, setFilters] = useState<Record<string, string>>({})
  
  const setFilter = useCallback((key: string, value: string) => {
    setFilters(prev => ({ ...prev, [key]: value }))
  }, [])
  
  // Memoize to prevent unnecessary re-renders
  const value = useMemo(
    () => ({ filters, setFilter }),
    [filters, setFilter]
  )
  
  return (
    <FilterContext.Provider value={value}>
      {children}
    </FilterContext.Provider>
  )
}

// Still re-renders when ANY filter changes
function ProductNameFilter() {
  const { filters } = useContext(FilterContext)
  // This component re-renders when filters.category changes too!
  return <input value={filters.name || ''} />
}

Even with memoization, components still re-render when unrelated parts of the state change. You'd need to split into multiple contexts or use selectors—which is exactly what state management libraries provide.

Feature-Scoped State Needs a Different Tool

Not all state is global.

In fact, most state in large applications is feature-scoped.

Feature-scoped state:

  • Belongs to a single route or feature
  • Is irrelevant once the user navigates away
  • Should reset naturally when the feature unmounts
  • Often updates frequently
  • Is consumed by multiple components

This state should not live in Redux.

It should not live in global Context.

And it should not live forever.

Enter Zustand: State Management Without the Ceremony

Zustand is a lightweight state management library designed specifically to solve these problems.

What makes it effective for feature-scoped state:

  • Selector-based subscriptions - Components re-render only when the state they select changes.
  • No providers required - No deep trees of <SomethingProvider> components.
  • Natural feature scoping - Stores can be colocated with features.
  • Predictable performance - Re-render behavior is explicit and controlled.
  • Minimal API surface - Easy to reason about and hard to misuse.

Zustand provides state management, not dependency injection—and that distinction matters.

Example: Zustand for Feature-Scoped State

// stores/filterStore.ts
import { create } from 'zustand'

interface FilterState {
  filters: Record<string, string>
  setFilter: (key: string, value: string) => void
  clearFilters: () => void
}

export const useFilterStore = create<FilterState>((set) => ({
  filters: {},
  setFilter: (key, value) =>
    set((state) => ({
      filters: { ...state.filters, [key]: value },
    })),
  clearFilters: () => set({ filters: {} }),
}))

// Component that only subscribes to the 'name' filter
function ProductNameFilter() {
  // ✅ Only re-renders when filters.name changes
  const nameFilter = useFilterStore((state) => state.filters.name)
  const setFilter = useFilterStore((state) => state.setFilter)
  
  return (
    <input
      value={nameFilter || ''}
      onChange={(e) => setFilter('name', e.target.value)}
    />
  )
}

// Another component that only cares about category
function CategoryFilter() {
  // ✅ Only re-renders when filters.category changes
  const categoryFilter = useFilterStore((state) => state.filters.category)
  const setFilter = useFilterStore((state) => state.setFilter)
  
  return (
    <select
      value={categoryFilter || ''}
      onChange={(e) => setFilter('category', e.target.value)}
    >
      {/* options */}
    </select>
  )
}

Key Benefit: ProductNameFilter only re-renders when filters.name changes, not when filters.category changes. This is automatic with Zustand's selector-based subscriptions.

Example: Feature-Scoped Store Lifecycle

// features/products/stores/productFilters.ts
import { create } from 'zustand'

interface ProductFilters {
  search: string
  category: string
  priceRange: [number, number]
  setSearch: (search: string) => void
  setCategory: (category: string) => void
  setPriceRange: (range: [number, number]) => void
}

// This store is only imported/used within the products feature
// When the feature unmounts, the store can be garbage collected
export const useProductFilters = create<ProductFilters>((set) => ({
  search: '',
  category: '',
  priceRange: [0, 1000],
  setSearch: (search) => set({ search }),
  setCategory: (category) => set({ category }),
  setPriceRange: (priceRange) => set({ priceRange }),
}))

// features/products/ProductListPage.tsx
export function ProductListPage() {
  // Store is created when this feature mounts
  // Store can be garbage collected when this feature unmounts
  const search = useProductFilters((state) => state.search)
  const setSearch = useProductFilters((state) => state.setSearch)
  
  return (
    <div>
      <input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
      />
      {/* Product list */}
    </div>
  )
}

Server State Is Not Client State

Another common scaling mistake is storing API responses in global state.

Modern applications should treat server state as a separate concern.

This is where React Query (TanStack Query) shines:

  • Caching
  • Deduplication
  • Background refetching
  • Garbage collection
  • Controlled cache lifetimes

When a page unmounts:

  • Queries become inactive
  • Data is removed from memory after cacheTime
  • No manual cleanup required

This avoids persisting data the user may never need again.

Example: React Query for Server State

// ❌ BAD: Storing server state in global state
const ProductStore = create((set) => ({
  products: [],
  loading: false,
  error: null,
  fetchProducts: async () => {
    set({ loading: true })
    try {
      const products = await api.getProducts()
      set({ products, loading: false })
    } catch (error) {
      set({ error, loading: false })
    }
  },
}))

// ✅ GOOD: Using React Query for server state
import { useQuery } from '@tanstack/react-query'

function ProductList() {
  const { data: products, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: () => api.getProducts(),
    staleTime: 5 * 60 * 1000, // 5 minutes
    cacheTime: 10 * 60 * 1000, // 10 minutes
  })
  
  // When this component unmounts, React Query handles cleanup
  // Data is removed from memory after cacheTime expires
  
  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>
  
  return (
    <ul>
      {products?.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  )
}

Example: Combining Zustand and React Query

// Client state (filters) with Zustand
const useProductFilters = create((set) => ({
  search: '',
  category: '',
  setSearch: (search: string) => set({ search }),
  setCategory: (category: string) => set({ category }),
}))

// Server state with React Query
function ProductList() {
  const search = useProductFilters((state) => state.search)
  const category = useProductFilters((state) => state.category)
  
  // Query automatically refetches when search or category changes
  const { data: products } = useQuery({
    queryKey: ['products', { search, category }],
    queryFn: () => api.getProducts({ search, category }),
  })
  
  return (
    <div>
      <SearchInput />
      <CategorySelect />
      <ProductGrid products={products} />
    </div>
  )
}

Choosing the Right Tool for Each Type of State

A scalable React architecture uses multiple tools, each for a specific job.

Local UI State

Tool: useState, useReducer

Examples: input values, toggles, temporary UI flags

function LoginForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [showPassword, setShowPassword] = useState(false)
  
  // This state is local to this component
  // No need for global state management
  return (
    <form>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type={showPassword ? 'text' : 'password'}
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button onClick={() => setShowPassword(!showPassword)}>
        Toggle
      </button>
    </form>
  )
}

Feature-Scoped Client State

Tool: Zustand

Examples: filters, selections, workflow steps, table coordination

// Feature-scoped store
const useCheckoutStore = create((set) => ({
  step: 1,
  shippingAddress: null,
  paymentMethod: null,
  nextStep: () => set((state) => ({ step: state.step + 1 })),
  setShippingAddress: (address) => set({ shippingAddress: address }),
}))

function CheckoutFlow() {
  const step = useCheckoutStore((state) => state.step)
  // Store is scoped to this feature
  // Cleans up when user navigates away
  return <div>{/* Checkout steps */}</div>
}

Server State

Tool: React Query

Examples: API responses, paginated lists, cached backend data

function UserProfile({ userId }: { userId: string }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => api.getUser(userId),
  })
  
  // React Query handles caching, refetching, and cleanup
  return <div>{user?.name}</div>
}

Global, Cross-App State

Tool: Redux (or Zustand for simpler cases)

Examples: authentication, user profile, permissions, feature flags

// Global auth store
const useAuthStore = create((set) => ({
  user: null,
  isAuthenticated: false,
  login: async (credentials) => {
    const user = await api.login(credentials)
    set({ user, isAuthenticated: true })
  },
  logout: () => set({ user: null, isAuthenticated: false }),
}))

// Used across the entire app
function App() {
  const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
  
  return isAuthenticated ? <Dashboard /> : <Login />
}

This separation keeps state:

  • Scoped correctly
  • Easy to reason about
  • Cheap to delete
  • Predictable in performance

Why Choose Zustand for Feature-Scoped State?

Zustand addresses several real problems:

1. Performance

Selective subscriptions prevent unnecessary re-renders.

// ✅ Zustand: Only re-renders when selected state changes
function FilterInput() {
  const value = useFilterStore((state) => state.filters.name)
  // Only re-renders when filters.name changes
}

// ❌ Context: Re-renders when any filter changes
function FilterInput() {
  const { filters } = useContext(FilterContext)
  // Re-renders when ANY filter changes
}

2. Maintainability

No provider hierarchies or manual memoization strategies.

// ✅ Zustand: No providers needed
export const useStore = create((set) => ({ /* ... */ }))

// ❌ Context: Requires provider setup
export function StoreProvider({ children }) {
  return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
}

3. Memory Efficiency

State exists only as long as the feature exists.

// Store is created when imported
// Can be garbage collected when feature unmounts
// No "forgotten" global state

4. Clear Ownership

State lives with the feature that owns it.

// features/products/stores/filters.ts
// State is colocated with the feature
export const useProductFilters = create(/* ... */)

This aligns with a simple but powerful principle:

State lifetime must match feature lifetime.

When this rule is followed:

  • Memory usage stays under control
  • Performance remains predictable
  • Refactoring becomes safe
  • Features can be removed cleanly

Common Pitfalls and How to Avoid Them

Pitfall 1: Over-selecting State

// ❌ BAD: Selecting entire state object
function Component() {
  const state = useStore() // Re-renders on ANY state change
  return <div>{state.name}</div>
}

// ✅ GOOD: Selecting only what you need
function Component() {
  const name = useStore((state) => state.name) // Only re-renders when name changes
  return <div>{name}</div>
}

Pitfall 2: Creating Stores in Render

// ❌ BAD: Creating store inside component
function Component() {
  const store = create((set) => ({ count: 0 })) // Creates new store on every render!
  return <div>{store.getState().count}</div>
}

// ✅ GOOD: Creating store at module level
const useStore = create((set) => ({ count: 0 }))

function Component() {
  const count = useStore((state) => state.count)
  return <div>{count}</div>
}

Pitfall 3: Mixing Server and Client State

// ❌ BAD: Storing server data in Zustand
const useProductsStore = create((set) => ({
  products: [],
  fetchProducts: async () => {
    const products = await api.getProducts()
    set({ products })
  },
}))

// ✅ GOOD: Using React Query for server state
function Products() {
  const { data: products } = useQuery({
    queryKey: ['products'],
    queryFn: () => api.getProducts(),
  })
  return <div>{/* render products */}</div>
}

Pitfall 4: Global State for Feature State

// ❌ BAD: Global state for feature-specific concerns
const useGlobalFilters = create((set) => ({
  // This state should be feature-scoped, not global
  filters: {},
}))

// ✅ GOOD: Feature-scoped state
// features/products/stores/filters.ts
const useProductFilters = create((set) => ({
  filters: {},
}))

Final Thoughts

Scaling React state management is less about choosing one "best" library and more about using the right abstraction for the right problem.

  • Context is excellent for dependency injection
  • React Query owns server state
  • Zustand simplifies feature-scoped client state
  • Redux remains valuable for true global coordination

When state is scoped intentionally and allowed to die when it's no longer needed, performance issues largely disappear—not because of clever optimizations, but because the architecture is doing less unnecessary work.

That is what scalable React state management really looks like.