Building Type-Safe React Context: A Practical Tutorial

Learn how to manage global state in React app using TypeScript and React Context. This step-by-step guide covers setup, creating a provider, using hooks, and integrating it into your app—ensuring type safety, encapsulation, and performance optimization.

4 min read
Building Type-Safe React Context: A Practical Tutorial

In this tutorial, we’ll build a simple but powerful React application to manage user preferences (such as theme and language) using React Context and TypeScript. Even if you’re new to React Context, by the end you’ll understand how it works, why it’s useful, and how to implement it in a type-safe manner.

Introduction

Imagine you’re building an application where users can toggle between a light and dark theme and switch languages. How can you manage this state across many components without passing props down through every layer? React Context is the answer. However, if you’re not familiar with Context or TypeScript, things might seem daunting. Don’t worry! We’ll break it down.

Step 1: Setting Up the Project

We’ll start with creating React app using the Vite TypeScript template. Follow these steps to set up your project:

Create the Project:

Open your terminal and run:

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install

Project Structure:

After setting up, your file structure should look similar to this:

my-app/
├── package.json
├── tsconfig.json
├── vite.config.ts
└── src/
    ├── main.tsx
    ├── App.tsx
    ├── contexts/
    │   └── UserPreferences.tsx
    └── components/
        └── ThemeToggle.tsx

We’ll add our new files (contexts/UserPreferences.tsx, and components/ThemeToggle.tsx) into the src folder.

Step 2: Creating the Context and Provider

First, let’s build our context. This is where we encapsulate our state and its update logic. Create a new file called src/contexts/UserPreferences.tsx:

// src/contexts/UserPreferences.tsx
import { createContext, PropsWithChildren, useContext, useMemo, useState } from 'react';

// Defining our types
interface UserPreferences {
    theme: 'light' | 'dark';
    language: 'en' | 'es' | 'fr';
}

interface UserPreferencesContextData extends UserPreferences {
    setTheme: (theme: UserPreferences['theme']) => void;
    setLanguage: (language: UserPreferences['language']) => void;
}

interface ProviderProps {
    initialPreferences?: Partial<UserPreferences>;
}

// Create our Context object with an initial null value.
const UserPreferencesContext = createContext<UserPreferencesContextData | null>(null);

// Helpful display name for debugging
UserPreferencesContext.displayName = 'UserPreferencesContext';

// A custom hook that initializes and returns our context state
function useCreateUserPreferences({
    initialPreferences = {},
}: ProviderProps): UserPreferencesContextData {
    const [theme, setTheme] = useState<UserPreferences['theme']>(
        initialPreferences.theme ?? 'light'
    );
    const [language, setLanguage] = useState<UserPreferences['language']>(
        initialPreferences.language ?? 'en'
    );

    // We use useMemo to prevent unnecessary re-renders.
    return useMemo(
        () => ({
            theme,
            language,
            setTheme,
            setLanguage,
        }),
        [theme, language]
    );
}

// Provider component that wraps your app and makes the context available
export function UserPreferencesProvider({ children, ...props }: PropsWithChildren<ProviderProps>) {
    const preferences = useCreateUserPreferences(props);

    return (
        <UserPreferencesContext.Provider value={preferences}>
            {children}
        </UserPreferencesContext.Provider>
    );
}

// Custom hook to use our context in any component
export function useUserPreferences() {
    const context = useContext(UserPreferencesContext);

    if (!context) {
        throw new Error('useUserPreferences must be used within a UserPreferencesProvider');
    }

    return context;
}

Breaking It Down

Creating the Context:
We initialize the context with a null default. This forces consumers to use our provider so they always get the correct data.

Provider Component:
UserPreferencesProvider wraps your components and supplies them with our state. The custom hook useCreateUserPreferences sets up the initial state and returns the state along with the updater functions.

Custom Hook (useUserPreferences):
This hook lets any component access the context. It also provides a helpful error if you forget to wrap your component tree in the provider.

Step 3: Using the Context in a Component

Let’s create a simple component that toggles the theme. Create src/components/ThemeToggle.tsx:

// src/contexts/ThemeToggle.tsx
import { useCallback } from 'react';

import { useUserPreferences } from '../contexts/UserPreferences';

export function ThemeToggle() {
    const { theme, setTheme } = useUserPreferences();

    const toggleTheme = useCallback(() => {
        setTheme(theme === 'light' ? 'dark' : 'light');
    }, [theme, setTheme]);

    return <button onClick={toggleTheme}>Toggle theme (Current: {theme})</button>;
}

Here, our ThemeToggle component uses the custom hook to get the current theme and the function to update it. When clicked, it toggles between "light" and "dark".

Step 4: Wrapping It All Together

Finally, we need to integrate our provider into the application. Update your main src/App.tsx file as follows:

// src/App.tsx
import { UserPreferencesProvider } from './contexts/UserPreferences';
import { ThemeToggle } from './components/ThemeToggle';

function App() {
  return (
    <UserPreferencesProvider initialPreferences={{ theme: 'dark' }}>
      <div style={{ padding: '2rem' }}>
        <h1>Type-Safe React Context Example</h1>
        <ThemeToggle />
      </div>
    </UserPreferencesProvider>
  );
}

export default App;

Running the Application

With everything in place, start your development server:

npm run dev

Open http://localhost:5173 (or the URL provided in your terminal) in your browser. You should see a page with a button that toggles the theme. The current theme is displayed on the button itself!

Recap: Why Use This Pattern?

Type Safety:
By defining our types, we ensure that any misuse of the context is caught at compile time. This helps prevent bugs and improves the developer experience.

Encapsulation:
The logic to manage state is isolated within a custom hook (useCreateUserPreferences) and a provider. This separation makes our code easier to test and maintain.

Performance:
Using useMemo ensures that the context values only update when necessary, reducing unnecessary re-renders in our components.

Developer Experience:
Clear error messages (like when useUserPreferences is used outside its provider) and a discoverable API make this pattern pleasant to work with.

Conclusion

In this tutorial, we walked through the process of creating a type-safe React Context to manage user preferences using Vite and TypeScript. We started by setting up the project with Vite, defined our types, built our context provider and custom hook, and finally integrated everything into a simple React app. This pattern not only makes your state management robust but also improves performance and developer experience.

By following this approach, you can scale your application and keep your state logic clean and well-organized. Happy coding!


Feel free to experiment and expand on this pattern—perhaps add more user preferences or split context domains as your application grows. Enjoy your journey into type-safe React Context!

Harduex blog


Follow