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.
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.