A practical guide by Samuel Owolabi
What if you don't actually need Zustand, Jotai, or Redux for your next project?
Hear me out. Those are fantastic libraries, but for a huge number of applications, you can build a powerful, scalable, and type-safe state management system using the tools React already gives you. The secret lies in combining React Context with the useReducer hook and wrapping it all in the warm, safe blanket of TypeScript.
The purpose of this guide is to show you exactly how to set up this pattern in a simple, practical way. We'll cover a real-world example to show how flexible this approach is. Don't be overwhelmed! By the end, you'll be able to manage complex state with confidence.
We'll build a state management system for a simple fintech dashboard. This will allow us to handle things like:
- User authentication (login/logout)
- A theme switcher (dark/light mode)
- User's wallet data (adding, removing, and updating wallets)
- A global token for API requests
- A selected currency for display
Ready? Let's dive in.
The Core Concepts: What Are We Using and Why?
First, let's quickly break down the tools we're bringing together.
What is React Context? 📝
React Context provides a way to pass data through the component tree without having to pass props down manually at every level. This is the perfect tool for sharing "global" data like the current user, theme, or language. It helps us avoid a problem called "prop drilling."
What is the useReducer Hook? ⚙️
useReducer is a React hook that you can, and should, use as an alternative to useState when you have complex state logic. Instead of just updating the state directly, you "dispatch" actions. A "reducer" function then takes the current state and an action and returns the new state. If you've ever used Redux, this pattern will feel very familiar. It helps keep your state transitions predictable and organized.
Why Add TypeScript to the Mix? 🛡️
TypeScript is a superset of JavaScript that adds static types. Here's why it's a game-changer for this pattern:
- Type Safety: It prevents you from putting the wrong kind of data into your state.
-
Amazing Autocomplete: TypeScript knows exactly what your state looks like. When you type
state.
, it will show you all the available properties. - Smarter Actions: It ensures you dispatch actions with the correct type and payload. For example, if your ADD_WALLET action needs a WalletType payload, TypeScript will give you an error if you try to send a simple string. This catches bugs before you even run the code.
The Folder Structure
We'll organize our context logic into a dedicated contexts folder. This keeps the state management code neatly separated from our UI components. For a Next.js App Router project, it might look like this:
/src
|-- /app
| |-- layout.tsx # We'll wrap our app here
| |-- usage-examples.tsx # Component to test our context
|
|-- /contexts
| |-- appContext.tsx # The main context provider file
| |-- reducer.tsx # The reducer function and initial state
| |-- reducerTypes.tsx # All our TypeScript types
Step 1: Define Your Types (reducerTypes.tsx)
This is the most important step for achieving type safety. We start by defining the "shape" of our state, the actions we can perform, and the context itself. By doing this first, we let TypeScript guide us through the rest of the implementation.
Here we define the shape of a user's wallet, the entire application state (ContextStateType), and most importantly, our actions. We use a discriminated union for ContextActionTypes. This is a fancy term for a pattern where each object type in the union has a common property (in our case, type) that TypeScript can use to figure out the exact shape of the action, including its payload.
contexts/reducerTypes.tsx
import { Dispatch } from 'react';
export type WalletType = {
currency: string,
availableBalance: number,
pendingBalance: number,
}
export type ContextStateType = {
theme: 'light' | 'dark',
selectedCurrency: string,
userData: {
firstName: string,
lastName: string,
email: string
},
token: string, // For API Requests Authentication
wallets: WalletType[]
}
// Discriminated union for our action types
export type ContextActionTypes = (
{
type: 'SET_THEME',
payload: ContextStateType['theme']
} | {
type: 'SET_SELECTED_CURRENCY',
payload: ContextStateType['selectedCurrency']
} | {
type: 'LOGIN_USER',
payload: ContextStateType['userData']
} | {
type: 'SET_TOKEN',
payload: ContextStateType['token']
} | {
type: 'LOGOUT_USER' // An action with no payload
} | {
type: 'SET_WALLETS',
payload: ContextStateType['wallets']
} | {
type: 'UPDATE_WALLET',
payload: WalletType
} | {
type: 'ADD_WALLET',
payload: WalletType
} | {
type: 'REMOVE_WALLET',
payload: WalletType['currency']
}
)
// Type for our reducer function
export type ReducerType = (
state: ContextStateType,
action: ContextActionTypes
) => ContextStateType;
// Type for the context value that will be provided
export type ContextValueType = {
state: ContextStateType;
dispatch: Dispatch<ContextActionTypes>;
}
Step 2: Create the Reducer Function (reducer.tsx)
The reducer is the heart of our state logic. It's a pure function that takes the previous state and an action, and returns the next state. We'll also define our initialState here.
Notice how the switch statement handles each action type we defined earlier. Thanks to our discriminated union, if action.type is 'ADD_WALLET', TypeScript knows that action.payload must be a WalletType object.
contexts/reducer.tsx
import { ContextStateType, ContextActionTypes, ReducerType } from './reducerTypes';
// Initial state for the reducer
export const initialState: ContextStateType = {
theme: 'light',
selectedCurrency: 'USD',
userData: {
firstName: '',
lastName: '',
email: ''
},
token: '',
wallets: []
};
export const reducer: ReducerType = (state: ContextStateType, action: ContextActionTypes): ContextStateType => {
switch (action.type) {
case 'SET_THEME':
return { ...state, theme: action.payload };
case 'SET_SELECTED_CURRENCY':
return { ...state, selectedCurrency: action.payload };
case 'LOGIN_USER':
return { ...state, userData: action.payload };
case 'SET_TOKEN':
return { ...state, token: action.payload };
case 'LOGOUT_USER':
return {
...state,
userData: initialState.userData,
token: ''
};
case 'SET_WALLETS':
return { ...state, wallets: action.payload };
case 'UPDATE_WALLET':
return {
...state,
wallets: state.wallets.map(wallet =>
wallet.currency === action.payload.currency
? { ...wallet, ...action.payload }
: wallet
)
};
case 'ADD_WALLET':
const walletExists = state.wallets.some(wallet => wallet.currency === action.payload.currency);
if (walletExists) {
// If wallet exists, update it instead of adding a duplicate
return {
...state,
wallets: state.wallets.map(wallet =>
wallet.currency === action.payload.currency
? action.payload
: wallet
)
};
}
// Add new wallet
return { ...state, wallets: [...state.wallets, action.payload] };
case 'REMOVE_WALLET':
return {
...state,
wallets: state.wallets.filter(wallet => wallet.currency !== action.payload)
};
default:
return state;
}
};
Step 3: Build the Context Provider (appContext.tsx)
Now we tie everything together. In this file, we:
- Create the context using
createContext()
. - Create the AppProvider component. This component will use our
useReducer
hook to create the state and dispatch function. It then makes them available to all of its children via the<AppContext.Provider>
component. - Create a custom hook
useAppContext()
. This is a best practice that makes consuming the context much cleaner and safer in our components. It abstracts the useContext call and also throws an error if we try to use the context outside of its provider.
contexts/appContext.tsx
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
import { ContextStateType, ContextActionTypes, ContextValueType } from './reducerTypes';
import { reducer, initialState } from './reducer';
// Create the context
const AppContext = createContext<ContextValueType | undefined>(undefined);
// Provider component props
interface AppProviderProps {
children: ReactNode;
}
// Provider component
export const AppProvider: React.FC<AppProviderProps> = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
const value: ContextValueType = {
state,
dispatch
};
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
};
// Custom hook to use the context
export const useAppContext = (): ContextValueType => {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useAppContext must be used within an AppProvider');
}
return context;
};
Step 4: Wrap Your App (layout.tsx)
For our context to be available everywhere, we need to wrap our entire application with the AppProvider we just created. In a Next.js app, the root layout.tsx is the perfect place to do this.
layout.tsx
import React, { ReactNode } from 'react';
import { AppProvider } from '@/contexts/appContext';
// Root Layout Component
export default async function RootLayout({
children,
}: {
children: ReactNode
}) {
return (
<html lang="en">
<body>
{/* Wrap the entire app with AppProvider */}
<AppProvider>
{children}
</AppProvider>
</body>
</html>
);
}
Step 5: Using Your New Context! (usage-examples.tsx)
Setup complete! Now for the fun part: using it.
In any component that's a child of AppProvider, you can now use your custom useAppContext
hook to get access to the state and the dispatch function.
Here are a few examples of how you might read data and dispatch actions.
app/usage-examples.tsx
import React from 'react';
import { useAppContext } from '@/contexts/appContext';
export const UsageExamples = () => {
const { state, dispatch } = useAppContext();
// --- Accessing State Data ---
const currentTheme = state.theme; // 'light' or 'dark'
const isLoggedIn = state.userData.email !== '';
const usdWallet = state.wallets.find(wallet => wallet.currency === 'USD');
// --- Dispatching Actions ---
// Toggle between light and dark theme
const toggleTheme = () => {
const newTheme = state.theme === 'light' ? 'dark' : 'light';
dispatch({
type: 'SET_THEME',
payload: newTheme
});
};
// Set user data when logging in
const loginUser = () => {
dispatch({
type: 'LOGIN_USER',
payload: {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@example.com'
}
});
// Try sending the wrong payload and see TypeScript complain!
// dispatch({ type: 'LOGIN_USER', payload: 'wrong data' });
};
// Add a new wallet
const addWallet = () => {
dispatch({
type: 'ADD_WALLET',
payload: {
currency: 'ETH',
availableBalance: 2.5,
pendingBalance: 0.1
}
});
};
// Remove wallet by currency
const removeWallet = () => {
dispatch({
type: 'REMOVE_WALLET',
payload: 'BTC' // Payload is just a string, as we defined!
});
};
// ... other functions
return (
<div>
<h2>Welcome, {isLoggedIn ? state.userData.firstName : 'Guest'}!</h2>
<p>Current Theme: {currentTheme}</p>
<button onClick={toggleTheme}>Toggle Theme</button>
<button onClick={loginUser}>Login</button>
{/* ... other UI elements */}
</div>
);
};
Bonus: How to Persist State on Refresh?
You're right, there's one problem. If you refresh the page, all your state disappears! We can solve this by syncing our state with the browser's localStorage.
Here is an enhanced version of appContext.tsx that saves the state to localStorage whenever it changes and loads it back on the initial render. This uses the logic from your appContextWithPersistence.tsx file.
contexts/appContext.tsx
import React, { createContext, useContext, useReducer, useEffect, ReactNode } from 'react';
import { ContextStateType, ContextActionTypes, ContextValueType } from './reducerTypes';
import { reducer, initialState } from './reducer';
const AppContext = createContext<ContextValueType | undefined>(undefined);
// Helper function to get initial state from localStorage
const getPersistedState = (): ContextStateType => {
if (typeof window === 'undefined') {
return initialState; // Default for server-side rendering
}
try {
const theme = localStorage.getItem('app_theme') as 'light' | 'dark' || initialState.theme;
const selectedCurrency = localStorage.getItem('app_selectedCurrency') || initialState.selectedCurrency;
const userData = JSON.parse(localStorage.getItem('app_userData') || 'null') || initialState.userData;
const wallets = JSON.parse(localStorage.getItem('app_wallets') || 'null') || initialState.wallets;
return {
...initialState, // Start with defaults
theme,
selectedCurrency,
userData,
wallets,
};
} catch (error) {
console.error('Error loading persisted state:', error);
return initialState;
}
};
// Helper function to save state to localStorage
const saveToStorage = (state: ContextStateType) => {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('app_theme', state.theme);
localStorage.setItem('app_selectedCurrency', state.selectedCurrency);
localStorage.setItem('app_userData', JSON.stringify(state.userData));
localStorage.setItem('app_wallets', JSON.stringify(state.wallets));
} catch (error) {
console.error('Error saving state to localStorage:', error);
}
};
export const AppProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
// Pass the getPersistedState function as the third argument to useReducer
const [state, dispatch] = useReducer(reducer, initialState, getPersistedState);
// Save state to localStorage whenever it changes
useEffect(() => {
saveToStorage(state);
}, [state]);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
};
export const useAppContext = (): ContextValueType => {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useAppContext must be used within an AppProvider');
}
return context;
};
Key changes:
- A
getPersistedState
function lazy-initializes our reducer's state with data from localStorage. We pass it as the third argument to useReducer. - A
saveToStorage
function is called inside a useEffect hook that listens for any changes to the state object. When a change occurs, it saves the new state to localStorage. We've also taken care not to persist sensitive data like an authentication token by always starting with the initialState.
To use this, you would simply import AppProvider from this persistence file instead of the original appContext.tsx in your layout.tsx.
Download full code on my GitHub repo
Conclusion
And there you have it! You've successfully built a robust, type-safe, and maintainable state management solution using only React's built-in tools and TypeScript.
While state management libraries like Redux, Zustand, and Jotai have their place, especially in very large-scale applications, this useContext + useReducer pattern is often more than enough. It gives you centralized logic, predictable state transitions, and top-tier developer experience thanks to TypeScript, all without adding another dependency to your project.
Happy coding!
This article was crafted with care by Samuel Owolabi. Find more of his work on his portfolio.
Top comments (0)