evolved.io logotype

#React #TypeScript #State Management #Performance #Redux #Context API #useSyncExternalStore

Replacing Redux with Optimized React Context

Build a Redux alternative using React Context and useSyncExternalStore for selective re-rendering. Learn how to prevent render cascades.

Dennis Gaidel
Dennis GaidelSeptember 30, 2025

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:

  1. StoreProvider: Manages state and subscriptions without exposing state in Context
  2. useStoreSelector: Hook that subscribes to specific state slices
  3. 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:

  1. stateRef: Stores current state without triggering Context changes
  2. listenersRef: Manages the subscriber set without re-creating on each render
  3. useEffect Notification: Notifies listeners after React commits state updates
  4. Stable Context Value: Excludes state from Context to prevent cascade re-renders
  5. subscribe Function: Adds/removes listeners for useSyncExternalStore
  6. getState Function: Returns current state snapshot from ref
  7. Memoization: dispatch is stable from useReducer, while getState and subscribe are memoized with useCallback

The Complete Update Flow:

When a component dispatches an action, here's what happens:

  1. Component calls dispatch(action)
  2. useReducer updates the state
  3. useEffect runs and notifies all listeners
  4. Each listener (via useSyncExternalStore) checks if its selected data changed
  5. 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:

  1. Subscribe to store changes via useSyncExternalStore
  2. Run selector on current state to extract desired data
  3. React compares previous vs current selector result (shallow comparison)
  4. 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

  1. Never put state directly in Context - Use refs to store state, only put dispatch, getState, and subscribe in Context value

  2. Notify listeners after state updates - Use useEffect to notify subscribers after React commits state changes

  3. Use useSyncExternalStore for subscriptions - This is what enables selective re-rendering through shallow comparison

  4. Create dedicated hooks for common slices - Improves API ergonomics and code readability

  5. 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 updates
  • listenersRef: Manages the set of subscriber callbacks
  • Context: 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 or SWR)
  • 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.