DEV Community

Cover image for Don't Reach for Redux Just Yet: Mastering State with React Context, useReducer, and TypeScript
Samuel Owolabi
Samuel Owolabi

Posted on • Edited on

Don't Reach for Redux Just Yet: Mastering State with React Context, useReducer, and TypeScript

React Context, useReducer, and TypeScript

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
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
};
Enter fullscreen mode Exit fullscreen mode

Step 3: Build the Context Provider (appContext.tsx)

Now we tie everything together. In this file, we:

  1. Create the context using createContext().
  2. 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.
  3. 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;
};
Enter fullscreen mode Exit fullscreen mode

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>
    );
}
Enter fullscreen mode Exit fullscreen mode

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>
    );
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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)