Technology · React

React useContext Advanced Patterns

Advanced patterns for Context API, custom hooks, and state management.

TL;DR
  1. 01Create custom hooks to simplify Context usage.
  2. 02Combine Context with useReducer for complex state.
  3. 03Split contexts to avoid cascading re-renders.

Custom Context Hooks

  • Build a custom hook to simplify Context access.
    const UserContext = createContext();
    
    export function UserProvider({ children }) {
      const [user, setUser] = useState(null);
      
      return (
        <UserContext.Provider value={{ user, setUser }}>
          {children}
        </UserContext.Provider>
      );
    }
    
    export function useUser() {
      const context = useContext(UserContext);
      if (!context) {
        throw new Error("useUser must be in UserProvider");
      }
      return context;
    }
    
    // Usage
    function Profile() {
      const { user, setUser } = useUser();
      return <div>{user?.name}</div>;
    }
  • Custom hooks provide error checking and a cleaner API.
  • Add derived values to the hook to avoid repeating logic in consumers.
    export function useAuth() {
      const { user } = useContext(AuthContext);
      return {
        user,
        isLoggedIn: !!user,
        isAdmin: user?.role === "admin",
        displayName: user?.name ?? "Guest"
      };
    }
  • Keep the raw context private — only export the custom hook.
    // auth-context.js
    const AuthContext = createContext(); // not exported
    
    export function AuthProvider({ children }) { /* ... */ }
    export function useAuth() { return useContext(AuthContext); }
  • Add a guard to the hook so consumers see a clear error.
    export function useTheme() {
      const ctx = useContext(ThemeContext);
      if (ctx === undefined) {
        throw new Error("useTheme must be called inside ThemeProvider");
      }
      return ctx;
    }

Context with useReducer

  • Combine Context and useReducer for complex state.
    const TodoContext = createContext();
    
    function todoReducer(state, action) {
      switch (action.type) {
        case "ADD_TODO":
          return [...state, action.payload];
        case "REMOVE_TODO":
          return state.filter(t => t.id !== action.payload);
        case "TOGGLE_TODO":
          return state.map(t =>
            t.id === action.payload ? { ...t, done: !t.done } : t
          );
        default:
          return state;
      }
    }
    
    export function TodoProvider({ children }) {
      const [todos, dispatch] = useReducer(todoReducer, []);
      
      return (
        <TodoContext.Provider value={{ todos, dispatch }}>
          {children}
        </TodoContext.Provider>
      );
    }
  • useReducer scales better than useState for complex logic.
  • Split state and dispatch into separate contexts for performance.
    const TodoStateContext = createContext();
    const TodoDispatchContext = createContext();
    
    export function TodoProvider({ children }) {
      const [todos, dispatch] = useReducer(todoReducer, []);
      return (
        <TodoStateContext.Provider value={todos}>
          <TodoDispatchContext.Provider value={dispatch}>
            {children}
          </TodoDispatchContext.Provider>
        </TodoStateContext.Provider>
      );
    }
  • Use action creators to keep dispatch calls clean.
    // actions.js
    export const addTodo = (text) => ({ type: "ADD_TODO", payload: { id: Date.now(), text, done: false } });
    export const removeTodo = (id) => ({ type: "REMOVE_TODO", payload: id });
    
    // In component:
    dispatch(addTodo("Buy milk"));
  • Pass initial state as a prop for easier testing.
    export function TodoProvider({ initialTodos = [], children }) {
      const [todos, dispatch] = useReducer(todoReducer, initialTodos);
      return (
        <TodoContext.Provider value={{ todos, dispatch }}>
          {children}
        </TodoContext.Provider>
      );
    }

Splitting Contexts

  • Create separate contexts for independent state.
    const AuthContext = createContext();
    const ThemeContext = createContext();
    const LocaleContext = createContext();
    
    export function AppProvider({ children }) {
      return (
        <AuthContext.Provider value={authValue}>
          <ThemeContext.Provider value={themeValue}>
            <LocaleContext.Provider value={localeValue}>
              {children}
            </LocaleContext.Provider>
          </ThemeContext.Provider>
        </AuthContext.Provider>
      );
    }
    
    export const useAuth = () => useContext(AuthContext);
    export const useTheme = () => useContext(ThemeContext);
    export const useLocale = () => useContext(LocaleContext);
  • Each context updates independently.
  • Components only re-render when their own context changes.
  • Split one large context by frequency of change.
    // userProfile changes rarely — separate from uiState that changes often
    const UserProfileContext = createContext();
    const UIStateContext = createContext();
  • Compose providers into a single wrapper component.
    export function Providers({ children }) {
      return (
        <AuthProvider>
          <ThemeProvider>
            <LocaleProvider>
              {children}
            </LocaleProvider>
          </ThemeProvider>
        </AuthProvider>
      );
    }
    // Use: <Providers><App /></Providers>

Lazy Initialization

  • Initialize context state from external sources.
    export function UserProvider({ children }) {
      const [user, setUser] = useState(() => {
        // Load from localStorage
        const saved = localStorage.getItem("user");
        return saved ? JSON.parse(saved) : null;
      });
      
      useEffect(() => {
        // Persist to localStorage
        localStorage.setItem("user", JSON.stringify(user));
      }, [user]);
      
      return (
        <UserContext.Provider value={{ user, setUser }}>
          {children}
        </UserContext.Provider>
      );
    }
  • Lazy initialization runs only on mount.
  • Persists state changes automatically.

Context with Async Operations

  • Handle async operations in Context.
    export function UserProvider({ children }) {
      const [user, setUser] = useState(null);
      const [loading, setLoading] = useState(false);
      
      const login = async (email, password) => {
        setLoading(true);
        try {
          const user = await authenticate(email, password);
          setUser(user);
        } finally {
          setLoading(false);
        }
      };
      
      return (
        <UserContext.Provider value={{ user, login, loading }}>
          {children}
        </UserContext.Provider>
      );
    }
    
    // Usage
    function LoginForm() {
      const { login, loading } = useUser();
      
      async function handleSubmit(email, password) {
        await login(email, password);
      }
      
      return <form onSubmit={handleSubmit}>...</form>;
    }

Tip: Always create a custom hook for your Context — it simplifies usage and allows you to add logic without changing consumers.

Warning: Don't put all state in one Context — split by domain to avoid unnecessary re-renders of unrelated components.

React TestingReact useRef Hook