Redux has long been the go-to solution for global state management in React applications. However, many teams find Redux's boilerplate overhead excessive for their needs.
Modern libraries like React Query and SWR have fundamentally changed state management by handling server-side state independently. This reduces global client state to a minimum: often just UI preferences, form state, and client-side caches. With this reduced scope, a React-only solution becomes viable.
The React Context API seems like a natural alternative to Redux, but traditional Context implementations have a critical flaw: every subscribed component re-renders whenever any part of the state changes. This "render cascade" makes Context appear unviable for complex applications.
This article shows you how to build a Redux alternative using React Context with selective re-rendering: components only update when their specific state slice changes.
The Problem with Traditional Context
The Render Cascade Issue
Here's a typical Context implementation:
// ❌ Traditional approach - causes render cascades
type AppState = {
preferences: Preferences
toasts: Toast[]
sidebar: Sidebar
}
const StoreContext = createContext<AppState>(null!)
export const StoreProvider = ({ children }: PropsWithChildren) => {
const [state, setState] = useState<AppState>(initialState)
return (
<StoreContext.Provider value={state}>
{children}
</StoreContext.Provider>
)
}
// Component that only needs theme preference
export const ThemeIndicator = () => {
const state = useContext(StoreContext)
return <div>Theme: {state.preferences.theme}</div>
}
The Problem: When you update toasts
or sidebar
, the ThemeIndicator
component re-renders even though it only cares about preferences.theme
.
With 50+ components consuming different parts of your state, every state update triggers unnecessary re-renders across your entire application.
Why Common Solutions Don't Work
"Just use useMemo
!"
// ❌ Doesn't prevent re-renders
const ThemeIndicator = () => {
const state = useContext(StoreContext)
const theme = useMemo(() => state.preferences.theme, [state.preferences.theme])
return <div>Theme: {theme}</div>
}
The component still re-renders because the Context value changed. useMemo
only prevents expensive calculations within the component; it doesn't prevent the render itself.
"Use Reselect
for memoization!"
// ❌ Still doesn't prevent re-renders
const selectTheme = createSelector(
(state: AppState) => state.preferences,
(prefs) => prefs.theme
)
const ThemeIndicator = () => {
const state = useContext(StoreContext)
const theme = selectTheme(state)
return <div>Theme: {theme}</div>
}
Same issue. The selector prevents recalculations, but the component still re-renders when the Context changes.
"Split into multiple Contexts!"
// ❌ Maintenance nightmare
const PreferencesContext = createContext<Preferences>(null!)
const ToastsContext = createContext<Toast[]>(null!)
const SidebarContext = createContext<Sidebar>(null!)
// Now you need 3 providers wrapping your app
<PreferencesProvider>
<ToastsProvider>
<SidebarProvider>
{children}
</SidebarProvider>
</ToastsProvider>
</PreferencesProvider>
This works but creates an unmaintainable mess of providers and loses the benefits of a unified store.
The Solution: useSyncExternalStore
with Selectors
React 18 introduced useSyncExternalStore
, originally designed for external state libraries. This hook gives us the key to selective re-rendering.
Note: This approach requires React 18 or later. If you're on React 17 or earlier, you'll need to upgrade or use the use-sync-external-store
shim package.
How useSyncExternalStore
Works
This hook lets components subscribe to specific slices of state:
const value = useSyncExternalStore(
subscribe, // Function to subscribe to changes
getSnapshot, // Function to get current value
getServerSnapshot // SSR fallback (same as getSnapshot for CSR)
)
React only re-renders the component when the returned value from getSnapshot
changes (shallow comparison). This enables selective re-rendering.
Architecture Overview
Our solution has three core pieces:
- StoreProvider: Manages state and subscriptions without exposing state in Context
- useStoreSelector: Hook that subscribes to specific state slices
- Dedicated Hooks: Convenience hooks for common state slices
Step-by-Step Implementation
Step 1: Define Your State Structure
// types.ts
export type Preferences = {
theme: 'light' | 'dark'
language: 'en' | 'de' | 'es'
}
export type Toast = {
id: string
message: string
type: 'info' | 'success' | 'error'
}
export type Sidebar = {
open: boolean
}
export type AppState = {
preferences: Preferences
toasts: Toast[]
sidebar: Sidebar
}
Step 2: Create Actions and Reducer
// actions.ts
export const enum PreferencesActionType {
SET_THEME = 'preferences/setTheme',
SET_LANGUAGE = 'preferences/setLanguage'
}
export const enum ToastActionType {
ADD_TOAST = 'toasts/add',
REMOVE_TOAST = 'toasts/remove'
}
export const enum SidebarActionType {
TOGGLE = 'sidebar/toggle'
}
export type Action =
| { type: PreferencesActionType.SET_THEME; payload: 'light' | 'dark' }
| { type: PreferencesActionType.SET_LANGUAGE; payload: 'en' | 'de' | 'es' }
| { type: ToastActionType.ADD_TOAST; payload: Toast }
| { type: ToastActionType.REMOVE_TOAST; payload: string }
| { type: SidebarActionType.TOGGLE }
// reducer.ts
export const rootReducer = (state: AppState, action: Action): AppState => {
switch (action.type) {
case PreferencesActionType.SET_THEME:
return {
...state,
preferences: { ...state.preferences, theme: action.payload }
}
case PreferencesActionType.SET_LANGUAGE:
return {
...state,
preferences: { ...state.preferences, language: action.payload }
}
case ToastActionType.ADD_TOAST:
return { ...state, toasts: [...state.toasts, action.payload] }
case ToastActionType.REMOVE_TOAST:
return {
...state,
toasts: state.toasts.filter(t => t.id !== action.payload)
}
case SidebarActionType.TOGGLE:
return { ...state, sidebar: { open: !state.sidebar.open } }
default:
return state
}
}
Step 3: Build the Store Provider
This is where the magic happens:
// store-provider.tsx
import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer, useRef } from 'react'
import { Action, AppState, rootReducer } from './types'
type StoreContextProps = {
dispatch: (action: Action) => void
getState: () => AppState
subscribe: (listener: () => void) => () => void
}
export const StoreContext = createContext<StoreContextProps>(null!)
const initialState: AppState = {
preferences: {
theme: 'light',
language: 'en'
},
toasts: [],
sidebar: {
open: false
}
}
export const StoreProvider = ({ children }: PropsWithChildren): React.ReactElement => {
const [state, dispatch] = useReducer(rootReducer, initialState)
// CRITICAL: Store state in a ref, not in the Context value
// This prevents Context from changing on every state update
const stateRef = useRef(state)
stateRef.current = state
// Store subscribers who want to be notified of state changes
// Think of this as a "mailing list" - components subscribe when they mount,
// get notified of changes, and unsubscribe when they unmount
const listenersRef = useRef(new Set<() => void>())
// Notify all subscribers after state updates
// React batches multiple state updates, so this runs once per batch
// Notifications happen after the commit phase completes
useEffect(() => {
for (const listener of listenersRef.current) {
listener()
}
}, [state])
// Subscribe function for useSyncExternalStore
// Components call this to register for updates
// Returns cleanup function that's called on unmount or re-subscription
const subscribe = useCallback((listener: () => void): (() => void) => {
listenersRef.current.add(listener)
return () => {
listenersRef.current.delete(listener)
}
}, [])
// Get current state snapshot
const getState = useCallback(() => stateRef.current, [])
// Memoize context value to prevent unnecessary re-renders
// CRITICAL: We do NOT include state in this object
// This keeps the Context stable while still notifying subscribers
// Note: dispatch is already stable from useReducer
// getState and subscribe are memoized with useCallback
const contextValue = React.useMemo(() => ({
dispatch,
getState,
subscribe
}), [dispatch, getState, subscribe])
return (
<StoreContext.Provider value={contextValue}>
{children}
</StoreContext.Provider>
)
}
Key Implementation Details:
stateRef
: Stores current state without triggering Context changeslistenersRef
: Manages the subscriber set without re-creating on each renderuseEffect
Notification: Notifies listeners after React commits state updates- Stable Context Value: Excludes state from Context to prevent cascade re-renders
subscribe
Function: Adds/removes listeners foruseSyncExternalStore
getState
Function: Returns current state snapshot from ref- Memoization:
dispatch
is stable fromuseReducer
, whilegetState
andsubscribe
are memoized withuseCallback
The Complete Update Flow:
When a component dispatches an action, here's what happens:
- Component calls
dispatch(action)
useReducer
updates the stateuseEffect
runs and notifies all listeners- Each listener (via
useSyncExternalStore
) checks if its selected data changed - Only components whose selected data changed re-render
This selective notification is what prevents render cascades.
Step 4: Create the Selector Hook
This hook enables selective re-rendering by letting components subscribe to specific state slices:
How it works:
- Subscribe to store changes via
useSyncExternalStore
- Run selector on current state to extract desired data
- React compares previous vs current selector result (shallow comparison)
- Component re-renders only if the selected data changed
Why this prevents cascades:
- Context value stays stable (doesn't contain state)
- Only the selector result determines re-rendering
- Preferences change → only components selecting preferences re-render
- Toasts change → only components selecting toasts re-render
Selector stability: Inline selectors create new function references on each render, but useSyncExternalStore
handles this correctly. The selector function itself doesn't affect subscriptions—only the returned value matters. For expensive computations, you can memoize selectors with useMemo
, but this is optional.
// use-store-selector.ts
import { useContext, useSyncExternalStore } from 'react'
import { StoreContext } from './store-provider'
type Selector<T> = (state: AppState) => T
export const useStoreSelector = <T>(selector: Selector<T>): T => {
const context = useContext(StoreContext)
if (!context) {
throw new Error('useStoreSelector must be used within a StoreProvider')
}
const { getState, subscribe } = context
return useSyncExternalStore(
subscribe,
() => selector(getState()),
() => selector(getState()) // SSR fallback
)
}
Usage examples:
// Simple property access
const preferences = useStoreSelector(state => state.preferences)
const theme = useStoreSelector(state => state.preferences.theme)
// Derived/computed values
const errorCount = useStoreSelector(
state => state.toasts.filter(t => t.type === 'error').length
)
Step 5: Create Dedicated Hooks for Common Slices
While useStoreSelector
provides maximum flexibility, dedicated hooks improve developer experience and reduce boilerplate:
// use-preferences.ts
export const usePreferences = () => useStoreSelector(state => state.preferences)
// use-toasts.ts
export const useToasts = () => useStoreSelector(state => state.toasts)
// use-sidebar.ts
export const useSidebar = () => useStoreSelector(state => state.sidebar)
For accessing dispatch:
// use-store.ts
import { useContext } from 'react'
import { StoreContext } from './store-provider'
export const useStore = () => {
const context = useContext(StoreContext)
if (!context) {
throw new Error('useStore must be used within a StoreProvider')
}
return { dispatch: context.dispatch }
}
These hooks provide:
- Simpler component code (no selector functions needed)
- Type safety with proper inference
- Consistent patterns for accessing state
Step 6: Use in Components
// ThemeToggle.tsx - Only re-renders when preferences change
import { PreferencesActionType } from './actions'
import { usePreferences } from './use-preferences'
import { useStore } from './use-store'
export const ThemeToggle = () => {
const { theme } = usePreferences()
const { dispatch } = useStore()
const toggleTheme = () => {
dispatch({
type: PreferencesActionType.SET_THEME,
payload: theme === 'light' ? 'dark' : 'light'
})
}
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
)
}
// ToastList.tsx - Only re-renders when toasts change
import { ToastActionType } from './actions'
import { useToasts } from './use-toasts'
import { useStore } from './use-store'
export const ToastList = () => {
const toasts = useToasts()
const { dispatch } = useStore()
return (
<div className="toasts">
{toasts.map(toast => (
<div key={toast.id} className={`toast toast-${toast.type}`}>
{toast.message}
<button onClick={() => dispatch({
type: ToastActionType.REMOVE_TOAST,
payload: toast.id
})}>
Dismiss
</button>
</div>
))}
</div>
)
}
// SidebarToggle.tsx - Only re-renders when sidebar changes
import { SidebarActionType } from './actions'
import { useSidebar } from './use-sidebar'
import { useStore } from './use-store'
export const SidebarToggle = () => {
const { open } = useSidebar()
const { dispatch } = useStore()
return (
<button onClick={() => dispatch({ type: SidebarActionType.TOGGLE })}>
{open ? 'Close' : 'Open'} Sidebar
</button>
)
}
Verification: Proving It Works
Create a debug component to visualize render behavior:
// RenderDebugDemo.tsx
import { useRef } from 'react'
export const RenderDebugDemo = () => {
const { dispatch } = useStore()
return (
<div style={{ position: 'fixed', bottom: 0, right: 0, padding: 20 }}>
<PreferencesDebugBox />
<ToastsDebugBox />
<SidebarDebugBox />
<button onClick={() => dispatch({
type: PreferencesActionType.SET_THEME,
payload: 'dark'
})}>
Change Theme
</button>
<button onClick={() => dispatch({
type: ToastActionType.ADD_TOAST,
payload: { id: Date.now().toString(), message: 'Hello!', type: 'info' }
})}>
Add Toast
</button>
<button onClick={() => dispatch({
type: SidebarActionType.TOGGLE
})}>
Toggle Sidebar
</button>
</div>
)
}
const PreferencesDebugBox = () => {
const preferences = usePreferences()
const renderCount = useRef(0)
renderCount.current++
console.log(`PreferencesDebugBox rendered ${renderCount.current} time(s)`)
return (
<div style={{ border: '1px solid blue', padding: 10 }}>
Theme: {preferences.theme} (Renders: {renderCount.current})
</div>
)
}
const ToastsDebugBox = () => {
const toasts = useToasts()
const renderCount = useRef(0)
renderCount.current++
console.log(`ToastsDebugBox rendered ${renderCount.current} time(s)`)
return (
<div style={{ border: '1px solid green', padding: 10 }}>
Toasts: {toasts.length} (Renders: {renderCount.current})
</div>
)
}
const SidebarDebugBox = () => {
const sidebar = useSidebar()
const renderCount = useRef(0)
renderCount.current++
console.log(`SidebarDebugBox rendered ${renderCount.current} time(s)`)
return (
<div style={{ border: '1px solid red', padding: 10 }}>
Sidebar: {sidebar.open ? 'Open' : 'Closed'} (Renders: {renderCount.current})
</div>
)
}
Expected behavior:
- Click "Change Theme" → Only
PreferencesDebugBox
render count increases - Click "Add Toast" → Only
ToastsDebugBox
render count increases - Click "Toggle Sidebar" → Only
SidebarDebugBox
render count increases
Why Other Approaches Fall Short
As shown earlier, useMemo
and Reselect
don't prevent re-renders because they run during the render phase. The component has already re-rendered by the time these optimizations apply. They only prevent expensive calculations, not the render itself.
Why You Can't Consume Context Directly
// ❌ This would cause render cascades
const StoreContext = createContext<AppState>(initialState)
const ThemeIndicator = () => {
const state = useContext(StoreContext)
return <div>Theme: {state.preferences.theme}</div>
}
When you put state directly in Context, any state change creates a new Context value. React re-renders all consumers because the Context value changed, regardless of whether the specific data they use changed.
The solution: Only put dispatch
, getState
, and subscribe
in Context (these never change). The state lives in a ref, and components subscribe to specific slices via useSyncExternalStore
.
When to Use This Approach
✅ Use This Approach When:
1. You're Refactoring Away from Redux
- Already have a Redux-like architecture (actions, reducers)
- Want to reduce bundle size and complexity
- Don't need advanced Redux features (middleware, dev tools)
2. You Have Complex Global State
- Multiple unrelated state slices (preferences, toasts, sidebar)
- 20+ components consuming different parts of state
- Need to prevent render cascades
3. Performance Matters
- Have performance issues with traditional Context
- Need fine-grained control over re-renders
- Building a data-heavy dashboard or admin panel
4. Your Team Prefers Explicit APIs
- Want clear action types and reducer logic
- Prefer predictable state updates over mutation patterns
- Value type safety and centralized state management
❌ Don't Use This Approach When:
1. Simple Local State Suffices
// Just use useState for this
const [theme, setTheme] = useState('light')
If your state is only used in 2-3 components, prop drilling or local state is simpler.
2. You Need Advanced Features
- Time-travel debugging (Redux DevTools)
- Complex middleware (sagas, observables)
- State persistence with automatic rehydration
- Rich ecosystem of plugins
3. Server State Dominates
If 90% of your state comes from API calls, use React Query
, SWR
, or Apollo Client
instead. They handle caching, revalidation, and synchronization better than any global state solution.
4. Your State is Highly Interconnected If every state slice depends on every other slice, a normalized store like Redux with selectors might be more appropriate.
Key Takeaways
Critical Implementation Points
-
Never put state directly in Context - Use refs to store state, only put
dispatch
,getState
, andsubscribe
in Context value -
Notify listeners after state updates - Use
useEffect
to notify subscribers after React commits state changes -
Use
useSyncExternalStore
for subscriptions - This is what enables selective re-rendering through shallow comparison -
Create dedicated hooks for common slices - Improves API ergonomics and code readability
-
Memoize Context value correctly - Only include stable functions (
dispatch
,getState
,subscribe
), never include state itself
The Architecture in a Nutshell
StoreProvider Layer:
stateRef
: Holds current state without triggering Context updateslistenersRef
: Manages the set of subscriber callbacksContext
: Only contains stable functions (dispatch, getState, subscribe)useEffect
: Notifies all listeners after state commits
Subscription Layer (useSyncExternalStore):
- Subscribes component to store changes
- Runs selector to extract specific data
- React compares previous vs current selector result
- Component re-renders only if selected data changed
Component Layer:
- Components use
useStoreSelector
or dedicated hooks - Only re-render when their selected state slice changes
- Example: Theme component ignores toast/sidebar changes
Conclusion
React Context can replace Redux if you implement selective re-rendering with useSyncExternalStore
. This prevents the render cascade problem where every Context consumer re-renders on any state change.
Key benefits:
- Redux-like architecture (actions, reducers, predictable updates)
- Smaller bundle size (no external dependencies)
- Selective re-rendering (components only update when their data changes)
- Full TypeScript support
- Straightforward migration from Redux
When not to use this:
- Simple state (use
useState
) - Server state (use
React Query
orSWR
) - Advanced Redux features needed (middleware, time-travel debugging)
- Small apps where the optimization overhead isn't worth it
Choose based on your requirements, not assumptions about Context being "simpler." Test with the debug component and measure the actual performance impact in your application.