Technology · React
React useContext Advanced Patterns
Advanced patterns for Context API, custom hooks, and state management.
TL;DR
- 01Create custom hooks to simplify Context usage.
- 02Combine Context with useReducer for complex state.
- 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.