Design Converter
Education
Last updated on Mar 25, 2025
•16 mins read
Last updated on Mar 25, 2025
•16 mins read
State management is one of the hardest parts of building a React app. As your app grows, passing props through multiple layers becomes a pain. React has several solutions for this, useReducer and useContext being two of them.
This blog goes into the technical details of usereducer vs. usecontext so you can make an informed decision for your projects.
Before diving into comparisons, let's establish what each hook does and how they fit into React's ecosystem.
Without explicitly providing props across each tier of the component hierarchy, React context offers a means for components to share data. It's designed to solve the "prop drilling" problem where data needs to be passed through many nested components.
1// Creating context with an initial value 2const ThemeContext = React.createContext('light'); 3 4// Provider component 5function App() { 6 return ( 7 <ThemeContext.Provider value="dark"> 8 <Header /> 9 </ThemeContext.Provider> 10 ); 11} 12 13// Consumer component using the useContext hook 14function Header() { 15 const theme = React.useContext(ThemeContext); 16 return <h1>Current theme: {theme}</h1>; 17}
When context data changes, all components using that context re-render. This makes the context API ideal for values that are considered "global" for a component tree, such as themes, user data, or localization preferences.
The useReducer hook is a React hook function that offers an alternative to useState for managing complex state manipulations. It follows the Redux pattern and is particularly useful when state transitions depend on the previous state or when you have complex state objects.
1// Reducer function 2function todoReducer(state, action) { 3 switch (action.type) { 4 case 'ADD_TODO': 5 return [...state, { 6 id: Date.now(), 7 text: action.payload, 8 completed: false 9 }]; 10 case 'TOGGLE_TODO': 11 return state.map(todo => 12 todo.id === action.payload 13 ? {...todo, completed: !todo.completed} 14 : todo 15 ); 16 default: 17 return state; 18 } 19} 20 21// Component using useReducer 22function TodoList() { 23 const [todos, dispatch] = React.useReducer(todoReducer, []); 24 25 const addTodo = (text) => { 26 dispatch({ type: 'ADD_TODO', payload: text }); 27 }; 28 29 return ( 30 <div> 31 <button onClick={() => addTodo('New Task')}>Add Todo</button> 32 {todos.map(todo => ( 33 <div key={todo.id}> 34 <span>{todo.text}</span> 35 </div> 36 ))} 37 </div> 38 ); 39}
The useReducer hook takes two arguments: the reducer function and the initial state. It returns the current state and a dispatch function that allows you to trigger state updates.
Let's examine some key aspects where these hooks differ and how they can complement each other.
useReducer:
• Centralizes state logic in a pure function (the reducer)
• Makes state transitions explicit through action objects
• Helps manage complex state objects with multiple sub-values
• Provides predictable state updates
useContext:
• Provides a way to pass data throughout the component tree
• Avoids prop drilling through intermediate components
• Creates common data that can be accessed throughout the component hierarchy
• Doesn't provide state management by itself
With the useReducer hook, your state logic is centralized in the reducer function, making it easier to test and maintain. All possible state transitions are defined in one place, using usual switch case statements to handle different action types.
React useContext , on the other hand, solves a different problem: it makes data available to all child components without passing props manually through each level. However, it doesn't provide any structure for updating this data.
An important detail to understand is how context changes affect rendering. Even if only a fraction of the data is used, all components that use that context will re-render when a context value changes.
useReducer by itself doesn't have this issue since components only re-render if the specific state they use changes.
1// This might cause performance issues 2function App() { 3 const [user, setUser] = useState({ name: 'John', preferences: { theme: 'dark' } }); 4 5 return ( 6 <UserContext.Provider value={user}> 7 {/* All children will re-render when any part of user changes */} 8 <UserProfile /> 9 <ThemeDisplay /> {/* Only needs theme but rerenders on name changes too */} 10 </UserContext.Provider> 11 ); 12}
A common pattern in React applications is to combine these two hooks. The useReducer hook manages state logic, while useContext distributes that state and the dispatch function to all the child components.
1// Create a context for state and dispatch 2const TodoContext = React.createContext(); 3 4// Reducer function 5function todoReducer(state, action) { 6 switch (action.type) { 7 case 'ADD_TODO': 8 return [...state, { id: Date.now(), text: action.payload, completed: false }]; 9 case 'TOGGLE_TODO': 10 return state.map(todo => 11 todo.id === action.payload ? {...todo, completed: !todo.completed} : todo 12 ); 13 default: 14 return state; 15 } 16} 17 18// Provider component 19function TodoProvider({ children }) { 20 const [todos, dispatch] = React.useReducer(todoReducer, []); 21 22 return ( 23 <TodoContext.Provider value={{ todos, dispatch }}> 24 {children} 25 </TodoContext.Provider> 26 ); 27} 28 29// Custom hook for using the todo context 30function useTodoContext() { 31 const context = React.useContext(TodoContext); 32 if (!context) { 33 throw new Error('useTodoContext must be used within a TodoProvider'); 34 } 35 return context; 36} 37 38// Component that adds todos 39function AddTodo() { 40 const { dispatch } = useTodoContext(); 41 const [text, setText] = React.useState(''); 42 43 const handleSubmit = (e) => { 44 e.preventDefault(); 45 if (text.trim()) { 46 dispatch({ type: 'ADD_TODO', payload: text }); 47 setText(''); 48 } 49 }; 50 51 return ( 52 <form onSubmit={handleSubmit}> 53 <input 54 value={text} 55 onChange={(e) => setText(e.target.value)} 56 placeholder="Add todo" 57 /> 58 <button type="submit">Add</button> 59 </form> 60 ); 61} 62 63// Component that displays todos 64function TodoList() { 65 const { todos, dispatch } = useTodoContext(); 66 67 return ( 68 <ul> 69 {todos.map(todo => ( 70 <li 71 key={todo.id} 72 style={{ textDecoration: todo.completed ? 'line-through' : 'none' }} 73 onClick={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })} 74 > 75 {todo.text} 76 </li> 77 ))} 78 </ul> 79 ); 80} 81 82// Main app component 83function App() { 84 return ( 85 <TodoProvider> 86 <h1>Todo App</h1> 87 <AddTodo /> 88 <TodoList /> 89 </TodoProvider> 90 ); 91}
This pattern allows you to:
Centralize state logic in a reducer function
Make state and dispatch available to all child components without prop drilling
Split UI components into smaller, focused pieces
Maintain good performance characteristics
Let's examine more specific aspects of these hooks and how they compare:
With useReducer:
• State is typically structured as a single state object or array
• Updates are handled through a dispatch function that sends actions to the reducer
• The reducer function determines how to update state based on action types
• State transitions are predictable and follow a consistent pattern
With useContext:
• The context can hold any value (objects, functions, primitives)
• Updates typically happen through functions provided in the context value
• There's no prescribed pattern for how updates should occur
• Context changes trigger re-renders in all consuming components
For applications with complex state manipulations, useReducer provides several advantages:
• State update logic is centralized rather than scattered across event handlers
• Actions describe what happened rather than how the state should change
• The current state is never mutated, ensuring predictable behavior
• Testing is simpler as reducers are pure functions
Here's an example of handling a more complex state with a completed flag based update:
1function taskReducer(state, action) { 2 switch (action.type) { 3 case 'ADD_TASK': 4 return { 5 ...state, 6 tasks: [...state.tasks, { 7 id: Date.now(), 8 description: action.payload, 9 completed: false, 10 priority: 'medium' 11 }] 12 }; 13 case 'UPDATE_TASK': 14 return { 15 ...state, 16 tasks: state.tasks.map(task => 17 task.id === action.payload.id 18 ? { ...task, ...action.payload.updates } 19 : task 20 ) 21 }; 22 case 'SET_FILTER': 23 return { 24 ...state, 25 filter: action.payload 26 }; 27 case 'MARK_COMPLETED': 28 return { 29 ...state, 30 tasks: state.tasks.map(task => 31 task.id === action.payload 32 ? { ...task, completed: true, completedAt: new Date() } 33 : task 34 ), 35 stats: { 36 ...state.stats, 37 completed: state.stats.completed + 1 38 } 39 }; 40 case 'HANDLE_ERROR': 41 return { 42 ...state, 43 error: action.payload, 44 isLoading: false 45 }; 46 default: 47 return state; 48 } 49} 50 51// Initial state 52const initialState = { 53 tasks: [], 54 filter: 'all', 55 stats: { 56 total: 0, 57 completed: 0 58 }, 59 isLoading: false, 60 error: null 61};
When using React context for global state, all components that consume that context will re-render whenever the context value changes. This can lead to performance issues in larger applications.
One approach to optimize this is to split your context into multiple smaller contexts:
1// Instead of one large context 2const AppContext = React.createContext(); 3 4// Use multiple focused contexts 5const UserContext = React.createContext(); 6const ThemeContext = React.createContext(); 7const NotificationContext = React.createContext(); 8 9function App() { 10 // State for different concerns 11 const [user, userDispatch] = React.useReducer(userReducer, initialUserState); 12 const [theme, setTheme] = React.useState('light'); 13 const [notifications, notificationDispatch] = React.useReducer(notificationReducer, []); 14 15 return ( 16 <UserContext.Provider value={{ user, dispatch: userDispatch }}> 17 <ThemeContext.Provider value={{ theme, setTheme }}> 18 <NotificationContext.Provider value={{ notifications, dispatch: notificationDispatch }}> 19 {/* App components */} 20 </NotificationContext.Provider> 21 </ThemeContext.Provider> 22 </UserContext.Provider> 23 ); 24}
With this approach, components only re-render when the specific context they use changes. Two components using different contexts won't cause unnecessary re-renders of each other.
This is one of the most common patterns for state management in React applications:
1// Create two contexts: one for state and one for dispatch 2const StateContext = React.createContext(); 3const DispatchContext = React.createContext(); 4 5// Reducer function 6function appReducer(state, action) { 7 // Implementation 8} 9 10// Provider component 11function AppProvider({ children }) { 12 const [state, dispatch] = React.useReducer(appReducer, initialState); 13 14 return ( 15 <StateContext.Provider value={state}> 16 <DispatchContext.Provider value={dispatch}> 17 {children} 18 </DispatchContext.Provider> 19 </StateContext.Provider> 20 ); 21} 22 23// Custom hooks to use the contexts 24function useState() { 25 const context = React.useContext(StateContext); 26 if (context === undefined) { 27 throw new Error('useState must be used within an AppProvider'); 28 } 29 return context; 30} 31 32function useDispatch() { 33 const context = React.useContext(DispatchContext); 34 if (context === undefined) { 35 throw new Error('useDispatch must be used within an AppProvider'); 36 } 37 return context; 38}
By separating state and dispatch into two contexts, components can choose to only subscribe to what they need. This is a powerful state management feature that minimizes unnecessary re-renders.
Not all complex state needs to be global. useReducer is excellent for managing complex local component state:
1function FormComponent() { 2 const [formState, dispatch] = React.useReducer(formReducer, initialFormState); 3 4 const handleChange = (e) => { 5 dispatch({ 6 type: 'FIELD_CHANGE', 7 field: e.target.name, 8 value: e.target.value 9 }); 10 }; 11 12 const handleSubmit = (e) => { 13 e.preventDefault(); 14 dispatch({ type: 'FORM_SUBMIT' }); 15 // API calls, etc. 16 }; 17 18 const handleBlur = (e) => { 19 dispatch({ 20 type: 'FIELD_BLUR', 21 field: e.target.name 22 }); 23 }; 24 25 return ( 26 <form onSubmit={handleSubmit}> 27 <input 28 name="username" 29 value={formState.username} 30 onChange={handleChange} 31 onBlur={handleBlur} 32 /> 33 {formState.errors.username && ( 34 <p className="error">{formState.errors.username}</p> 35 )} 36 {/* Other form fields */} 37 <button 38 type="submit" 39 disabled={formState.isSubmitting || !formState.isValid} 40 > 41 Submit 42 </button> 43 </form> 44 ); 45}
For larger applications, you might want to split your global state into different domains:
1function App() { 2 return ( 3 <AuthProvider> 4 <DataProvider> 5 <UIProvider> 6 <Routes /> 7 </UIProvider> 8 </DataProvider> 9 </AuthProvider> 10 ); 11}
Each provider can use its own reducer and context, focusing on a specific domain. This approach:
• Makes your code more maintainable
• Improves performance by limiting the scope of re-renders
• Follows the principle of separation of concerns
When you call React.createContext(), React creates a context object with a Provider and a Consumer. The Provider function accepts a value prop and makes that available to all child components.
When a component calls useContext(MyContext), React looks up the nearest MyContext.Provider in the component tree and uses its value. If there is no Provider, React uses the default value provided to createContext.
When the provider's value changes, React schedules an update for all components that consume that context.
The useReducer hook manages state updates through a reducer function. When you call dispatch, React:
Calls your reducer with the current state and the action
Stores the new state returned by the reducer
Schedules a re-render of your component with the new state
Unlike setState, which merges the update object with the previous state, useReducer completely replaces the old state with whatever is returned from the reducer function.
Using useReducer:
1function formReducer(state, action) { 2 switch (action.type) { 3 case 'FIELD_CHANGE': 4 return { 5 ...state, 6 values: { 7 ...state.values, 8 [action.field]: action.value 9 }, 10 touched: { 11 ...state.touched, 12 [action.field]: true 13 } 14 }; 15 case 'VALIDATE_FIELD': 16 const fieldError = validateField(action.field, state.values[action.field]); 17 return { 18 ...state, 19 errors: { 20 ...state.errors, 21 [action.field]: fieldError 22 } 23 }; 24 case 'FORM_SUBMIT': 25 return { 26 ...state, 27 isSubmitting: true, 28 submitCount: state.submitCount + 1 29 }; 30 case 'SUBMIT_SUCCESS': 31 return { 32 ...state, 33 isSubmitting: false, 34 isSubmitted: true, 35 submitError: null 36 }; 37 case 'SUBMIT_ERROR': 38 return { 39 ...state, 40 isSubmitting: false, 41 submitError: action.error 42 }; 43 default: 44 return state; 45 } 46}
Using Context for a Form Library:
1const FormContext = React.createContext(); 2 3function FormProvider({ initialValues, onSubmit, children }) { 4 const [state, dispatch] = React.useReducer(formReducer, { 5 values: initialValues, 6 touched: {}, 7 errors: {}, 8 isSubmitting: false, 9 isSubmitted: false, 10 submitCount: 0, 11 submitError: null 12 }); 13 14 // Methods to interact with the form 15 const setFieldValue = (field, value) => { 16 dispatch({ type: 'FIELD_CHANGE', field, value }); 17 }; 18 19 const handleSubmit = async (e) => { 20 e.preventDefault(); 21 dispatch({ type: 'FORM_SUBMIT' }); 22 23 try { 24 await onSubmit(state.values); 25 dispatch({ type: 'SUBMIT_SUCCESS' }); 26 } catch (error) { 27 dispatch({ type: 'SUBMIT_ERROR', error }); 28 } 29 }; 30 31 // Context value object with state and methods 32 const value = { 33 ...state, 34 setFieldValue, 35 handleSubmit 36 }; 37 38 return ( 39 <FormContext.Provider value={value}> 40 {children} 41 </FormContext.Provider> 42 ); 43} 44 45// Input field component 46function Field({ name, label }) { 47 const { values, touched, errors, setFieldValue } = React.useContext(FormContext); 48 49 return ( 50 <div> 51 <label htmlFor={name}>{label}</label> 52 <input 53 id={name} 54 name={name} 55 value={values[name] || ''} 56 onChange={(e) => setFieldValue(name, e.target.value)} 57 /> 58 {touched[name] && errors[name] && ( 59 <div className="error">{errors[name]}</div> 60 )} 61 </div> 62 ); 63}
This example shows how combining the useReducer hook with React context creates a mini form library where all the child components can access form state without prop drilling.
Authentication is a common use case for global state. Here's how you might implement it using the above two components:
1// Auth reducer 2function authReducer(state, action) { 3 switch (action.type) { 4 case 'LOGIN_REQUEST': 5 return { ...state, isLoading: true, error: null }; 6 case 'LOGIN_SUCCESS': 7 return { 8 ...state, 9 isLoading: false, 10 isAuthenticated: true, 11 user: action.payload 12 }; 13 case 'LOGIN_FAILURE': 14 return { 15 ...state, 16 isLoading: false, 17 isAuthenticated: false, 18 user: null, 19 error: action.payload 20 }; 21 case 'LOGOUT': 22 return { 23 ...state, 24 isAuthenticated: false, 25 user: null 26 }; 27 default: 28 return state; 29 } 30} 31 32// Auth context 33const AuthContext = React.createContext(); 34 35// Auth provider 36function AuthProvider({ children }) { 37 const [state, dispatch] = React.useReducer(authReducer, { 38 isLoading: false, 39 isAuthenticated: false, 40 user: null, 41 error: null 42 }); 43 44 // Methods 45 const login = async (credentials) => { 46 dispatch({ type: 'LOGIN_REQUEST' }); 47 try { 48 const user = await apiClient.login(credentials); 49 dispatch({ type: 'LOGIN_SUCCESS', payload: user }); 50 return user; 51 } catch (error) { 52 dispatch({ type: 'LOGIN_FAILURE', payload: error.message }); 53 throw error; 54 } 55 }; 56 57 const logout = () => { 58 apiClient.logout(); 59 dispatch({ type: 'LOGOUT' }); 60 }; 61 62 // Provider value 63 const value = { 64 ...state, 65 login, 66 logout 67 }; 68 69 return ( 70 <AuthContext.Provider value={value}> 71 {children} 72 </AuthContext.Provider> 73 ); 74} 75 76// Custom hook 77function useAuth() { 78 const context = React.useContext(AuthContext); 79 if (context === undefined) { 80 throw new Error('useAuth must be used within an AuthProvider'); 81 } 82 return context; 83} 84 85// Usage in components 86function LoginForm() { 87 const { login, isLoading, error } = useAuth(); 88 const [credentials, setCredentials] = React.useState({ email: '', password: '' }); 89 90 const handleSubmit = async (e) => { 91 e.preventDefault(); 92 try { 93 await login(credentials); 94 // Redirect or show success 95 } catch (err) { 96 // Error is already handled in the provider 97 } 98 }; 99 100 return ( 101 <form onSubmit={handleSubmit}> 102 {/* Form fields */} 103 {error && <div className="error">{error}</div>} 104 <button type="submit" disabled={isLoading}> 105 {isLoading ? 'Logging in...' : 'Log in'} 106 </button> 107 </form> 108 ); 109}
• You have complex state logic involving multiple sub-values
• The next state depends on the previous state
• You want to optimize performance for components that trigger deep updates
• You want to make state updates more predictable and easier to test
• You're familiar with Redux patterns and prefer that approach
• You need to share data across many components at different nesting levels
• You want to avoid prop drilling through intermediate components
• The shared data doesn't change frequently
• You need to create common data that can be accessed by many components
• You need global state management across your application
• You want the predictability of reducers with the convenience of context
• You're building a medium to large-sized application
• You want to avoid external state management libraries
When working with useContext and useReducer, here are some ways to optimize performance:
Split contexts by domain: Instead of one giant context, use multiple smaller contexts.
Memoize context values: Use useMemo to prevent unnecessary re-renders.
1function TodoProvider({ children }) { 2 const [todos, dispatch] = React.useReducer(todoReducer, []); 3 4 // Memoize the context value to prevent unnecessary re-renders 5 const contextValue = React.useMemo(() => { 6 return { todos, dispatch }; 7 }, [todos]); 8 9 return ( 10 <TodoContext.Provider value={contextValue}> 11 {children} 12 </TodoContext.Provider> 13 ); 14}
Use separate contexts for state and dispatch: Since dispatch functions from useReducer never change, you can put them in a separate context.
Use React.memo for components that consume context: This prevents re-renders when props haven't changed.
1const TodoItem = React.memo(function TodoItem({ todo }) { 2 const { dispatch } = useTodoContext(); 3 4 return ( 5 <li> 6 <span>{todo.text}</span> 7 <button onClick={() => dispatch({ type: 'REMOVE_TODO', payload: todo.id })}> 8 Delete 9 </button> 10 </li> 11 ); 12});
As a final step in our comparison, let's summarize the key differences and similarities between these two hooks:
Feature | useReducer | useContext |
---|---|---|
Purpose | Managing complex state | Sharing data across components |
Updates state? | Yes | No (only distributes it) |
Prevents prop drilling? | No | Yes |
Re-render behavior | Only re-renders components using the state | Re-renders all components using the context |
Learning curve | Moderate (requires understanding reducers) | Low (simple API) |
Best used for | Complex state logic | Avoiding prop drilling |
Can be used together? | Yes | Yes |
Both useReducer and useContext play different roles in React applications, but they work well together. useReducer helps manage complex state logic inside a component, while useContext makes it easier to share data across components without prop drilling.
For small projects, simple state management with useState might be enough. But as the app grows, using useReducer for structured state updates and useContext for global access can make things smoother.
Instead of choosing between useReducer vs useContext, think about how they can complement each other. Combining them creates a strong, built-in state management pattern without extra libraries.
Tired of manually designing screens, coding on weekends, and technical debt? Let DhiWise handle it for you!
You can build an e-commerce store, healthcare app, portfolio, blogging website, social media or admin panel right away. Use our library of 40+ pre-built free templates to create your first application using DhiWise.