Mastering React’s useContext Hook with TypeScript

Recently, while working on a React project with TypeScript, I needed to share state between multiple components. Managing shared data like user info, theme preferences, or a shopping cart across deeply nested components was becoming difficult with unorganized code.

While searching for an alternate I came to know about React’s Context API specifically the useContext hook.

In this article, I’ll explain how to use React’s useContext with TypeScript to create clean, type-safe shared state in your React applications.

What is useContext in React?

The useContext hook is part of React’s Context API that allows you to share values between components without explicitly passing props through every level of the component tree.

When working with TypeScript, we gain the additional benefit of type safety, but it requires proper configuration.

Check out: Convert TypeScript Objects to JSON

Setting Up Context with TypeScript

First, create a React + TypeScript Project, and for that, you can use Vite (fastest option):

npm create vite@latest usecontext-demo -- --template react-ts
cd usecontext-demo
npm install
npm run dev

To use Vite, we need the Node.js version above 20.0.

Method 1: Basic Context Creation

In the small React + TypeScript app using useContext to share user information across components.

Let’s start by creating a simple context for a user authentication system:

//UserContext.tsx
import { createContext } from "react";

type User = {
  name: string;
  age: number;
};

export const UserContext = createContext<User | null>(null);

Now we can use this context in our components:

  • UserProvider.tsx
//UserProvider.tsx
import React, { useState } from "react";
import { UserContext } from "./UserContext";

type Props = {
  children: React.ReactNode;
};

const UserProvider: React.FC<Props> = ({ children }) => {
  const [user] = useState({ name: "Henry", age: 28 });

  return (
    <UserContext.Provider value={user}>
      {children}
    </UserContext.Provider>
  );
};

export default UserProvider;
  • UserProfile.tsx
//UserProfile.tsx
import React, { useContext } from "react";
import { UserContext } from "./UserContext";

const UserProfile: React.FC = () => {
  const user = useContext(UserContext);

  if (!user) return <p>Loading...</p>;

  return (
    <div style={{ border: "1px solid #ccc", padding: "1rem", width: "250px" }}>
      <h2>{user.name}</h2>
      <p>Age: {user.age}</p>
    </div>
  );
};

export default UserProfile;
  • App.tsx
//App.tsx
import React from "react";
import UserProvider from "./UserProvider";
import UserProfile from "./UserProfile";

const App: React.FC = () => {
  return (
    <UserProvider>
      <h1>User Dashboard</h1>
      <UserProfile />
    </UserProvider>
  );
};

export default App;

In the terminal, enter npm run dev, then follow the link http://localhost:/

Use TypeScript in React

In this, we can see the value that we have provided from UserProvider.tsx to UserProfile.tsx.

Check out: Ternary Operator in TypeScript

Method 2: Using Context with Initial Values

Sometimes you’ll want to provide initial values to your context. Here’s how to do it safely with TypeScript:

Create context files.

// src/ShoppingCartContext.tsx
import React, { createContext, useContext, useReducer } from 'react';

interface Product {
  id: string;
  name: string;
  price: number;
}

interface CartItem {
  product: Product;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  total: number;
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: Product }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'CLEAR_CART' };

interface CartContextType {
  state: CartState;
  addItem: (product: Product) => void;
  removeItem: (productId: string) => void;
  clearCart: () => void;
}

const initialState: CartState = {
  items: [],
  total: 0
};

const cartReducer = (state: CartState, action: CartAction): CartState => {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existingItemIndex = state.items.findIndex(
        item => item.product.id === action.payload.id
      );

      let newItems: CartItem[];

      if (existingItemIndex >= 0) {
        newItems = [...state.items];
        newItems[existingItemIndex] = {
          ...newItems[existingItemIndex],
          quantity: newItems[existingItemIndex].quantity + 1
        };
      } else {
        newItems = [...state.items, { product: action.payload, quantity: 1 }];
      }

      return {
        items: newItems,
        total: newItems.reduce(
          (sum, item) => sum + item.product.price * item.quantity,
          0
        )
      };
    }

    case 'REMOVE_ITEM': {
      const newItems = state.items.filter(item => item.product.id !== action.payload);

      return {
        items: newItems,
        total: newItems.reduce(
          (sum, item) => sum + item.product.price * item.quantity,
          0
        )
      };
    }

    case 'CLEAR_CART':
      return initialState;

    default:
      return state;
  }
};

const CartContext = createContext<CartContextType>({
  state: initialState,
  addItem: () => {},
  removeItem: () => {},
  clearCart: () => {}
});

export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  const addItem = (product: Product) => {
    dispatch({ type: 'ADD_ITEM', payload: product });
  };

  const removeItem = (productId: string) => {
    dispatch({ type: 'REMOVE_ITEM', payload: productId });
  };

  const clearCart = () => {
    dispatch({ type: 'CLEAR_CART' });
  };

  return (
    <CartContext.Provider value={{ state, addItem, removeItem, clearCart }}>
      {children}
    </CartContext.Provider>
  );
};

export const useCart = () => useContext(CartContext);

Create ProductList.tsx in src/

// src/ProductList.tsx
import React from 'react';
import { useCart } from './ShoppingCartContext';

const products = [
  { id: '1', name: 'Apple iPhone 13', price: 799 },
  { id: '2', name: 'Samsung Galaxy S22', price: 749 },
  { id: '3', name: 'Google Pixel 6', price: 599 }
];

const ProductList: React.FC = () => {
  const { addItem } = useCart();

  return (
    <div>
      <h2>Available Products</h2>
      <ul>
        {products.map(product => (
          <li key={product.id}>
            {product.name} - ${product.price}
            <button onClick={() => addItem(product)}>Add to Cart</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default ProductList;

3. Create a Cart.tsx file to show cart data.

// src/Cart.tsx
import React from 'react';
import { useCart } from './ShoppingCartContext';

const Cart: React.FC = () => {
  const { state, removeItem, clearCart } = useCart();

  return (
    <div>
      <h2>Cart</h2>
      {state.items.length === 0 ? (
        <p>Your cart is empty.</p>
      ) : (
        <>
          <ul>
            {state.items.map(({ product, quantity }) => (
              <li key={product.id}>
                {product.name} (x{quantity}) - ${product.price * quantity}
                <button onClick={() => removeItem(product.id)}>Remove</button>
              </li>
            ))}
          </ul>
          <p>Total: ${state.total}</p>
          <button onClick={clearCart}>Clear Cart</button>
        </>
      )}
    </div>
  );
};

export default Cart;

Check out: TypeScript keyof Operator

Wrap the App with CartProvider edit src/App.tsx:

// src/App.tsx
import React from 'react';
import { CartProvider } from './ShoppingCartContext';
import ProductList from './ProductList';
import Cart from './Cart';

const App: React.FC = () => {
  return (
    <CartProvider>
      <h1>Shopping Cart Demo</h1>
      <ProductList />
      <Cart />
    </CartProvider>
  );
};

export default App;

Now, run the app with npm run dev.

Output:

UseContext in TypeScript

Method 3: Using Multiple Contexts Together

In complex applications, you might need to use multiple contexts together. Here’s how to organize this with TypeScript:

In the multiple contexts we will create contexts for user login, shopping cart and to change theme. In the src, create a folder contexts and inside that folder, create the contexts below.

  • contexts/UserContext.tsx
import React, { createContext, useContext, useState } from 'react';

interface User {
  id: string;
  name: string;
}

interface UserContextType {
  user: User | null;
  setUser: (user: User) => void;
}

const UserContext = createContext<UserContextType | undefined>(undefined);

export const UserProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [user, setUser] = useState<User | null>(null);

  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
};

export const useUser = () => {
  const context = useContext(UserContext);
  if (!context) throw new Error('useUser must be used within a UserProvider');
  return context;
};
  • contexts/ShoppingCartContext.tsx
import React, { createContext, useContext, useState } from 'react';

interface CartItem {
  id: string;
  name: string;
  quantity: number;
}

interface CartContextType {
  cart: CartItem[];
  addItem: (item: CartItem) => void;
}

const CartContext = createContext<CartContextType | undefined>(undefined);

export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [cart, setCart] = useState<CartItem[]>([]);

  const addItem = (item: CartItem) => {
    setCart((prev) => [...prev, item]);
  };

  return (
    <CartContext.Provider value={{ cart, addItem }}>
      {children}
    </CartContext.Provider>
  );
};

export const useCart = () => {
  const context = useContext(CartContext);
  if (!context) throw new Error('useCart must be used within a CartProvider');
  return context;
};
  • contexts/ThemeContext.tsx
import React, { createContext, useContext, useState } from 'react';

type Theme = 'light' | 'dark';

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [theme, setTheme] = useState<Theme>('light');

  const toggleTheme = () => {
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used within a ThemeProvider');
  return context;
};

Check out: Call REST APIs Using TypeScript

Now, create a context wrapper AppContexts.tsx inside the same contexts folder.

import React from 'react';
import { UserProvider } from './UserContext';
import { CartProvider } from './ShoppingCartContext';
import { ThemeProvider } from './ThemeContext';

export const AppProviders: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  return (
    <ThemeProvider>
      <UserProvider>
        <CartProvider>
          {children}
        </CartProvider>
      </UserProvider>
    </ThemeProvider>
  );
};

Inside the src, create a main component, MainLayout.tsx.

import React from 'react';
import { useUser } from './contexts/UserContext';
import { useCart } from './contexts/ShoppingCartContext';
import { useTheme } from './contexts/ThemeContext';

const MainLayout: React.FC = () => {
  const { user, setUser } = useUser();
  const { cart, addItem } = useCart();
  const { theme, toggleTheme } = useTheme();

  return (
    <div style={{ padding: '2rem', background: theme === 'dark' ? '#222' : '#fff', color: theme === 'dark' ? '#fff' : '#000' }}>
      <h1>Multi-Context App</h1>

      <section>
        <h2>User</h2>
        {user ? (
          <>
            <p>Hello, {user.name}</p>
            <button onClick={() => setUser(null)}>Logout</button>
          </>
        ) : (
          <button onClick={() => setUser({ id: '1', name: 'Henry' })}>Login</button>
        )}
      </section>

      <section>
        <h2>Cart</h2>
        <button onClick={() => addItem({ id: 'p1', name: 'Product 1', quantity: 1 })}>Add Item</button>
        <ul>
          {cart.map((item, idx) => (
            <li key={idx}>{item.name} x {item.quantity}</li>
          ))}
        </ul>
      </section>

      <section>
        <h2>Theme</h2>
        <p>Current Theme: {theme}</p>
        <button onClick={toggleTheme}>Toggle Theme</button>
      </section>
    </div>
  );
};

export default MainLayout;

Then, in your main App component:

import React from 'react';
import { AppProviders } from './contexts/AppContexts';
import MainLayout from './MainLayout';

const App: React.FC = () => {
  return (
    <AppProviders>
      <MainLayout />
    </AppProviders>
  );
};

export default App;

Output:

UseContext for multiple context in TypeScript

Check out: TypeScript Global Variables

Best Practices for Using useContext with TypeScript

1. Always Define Types for Your Context

Always define an explicit interface for your context value. This ensures type safety across your application.

// Bad: No explicit typing
const MyContext = createContext({});

// Good: Properly typed context
interface MyContextType {
  value: string;
  setValue: (value: string) => void;
}

const MyContext = createContext<MyContextType | undefined>(undefined);

2. Create Custom Hooks for Context Consumption

Create a custom hook for each context to encapsulate the error handling logic:

export const useMyContext = () => {
  const context = useContext(MyContext);
  if (context === undefined) {
    throw new Error('useMyContext must be used within a MyProvider');
  }
  return context;
};

3. Provide Default Values When Appropriate

For non-optional contexts, consider providing sensible defaults:

const ThemeContext = createContext<ThemeContextType>({
  theme: 'light',
  toggleTheme: () => {},
});

4. Split Complex Contexts

If your context is growing too large, consider splitting it into smaller, more focused contexts:

// Instead of one large context:
interface HugeContext {
  user: User;
  cart: Cart;
  theme: Theme;
  // many more properties...
}

// Split into focused contexts:
interface UserContext { user: User; }
interface CartContext { cart: Cart; }
interface ThemeContext { theme: Theme; }

Check out: Check If Object Is Undefined in TypeScript

Handling Context Updates Efficiently

To optimize performance when using context, consider using the useMemo hook to memoize your context value:

export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

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

  // Memoize the context value to prevent unnecessary re-renders
  const value = useMemo(() => ({ 
    theme, 
    toggleTheme 
  }), [theme, toggleTheme]);

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

Real-World Example: Theme Switcher

Let’s create a complete theme switcher example that’s commonly used in American websites:

// ThemeContext.tsx
import React, { createContext, useContext, useState, useMemo, useEffect } from 'react';

type Theme = 'light' | 'dark';

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  // Check if user has a preference stored in localStorage
  const savedTheme = localStorage.getItem('theme') as Theme | null;
  const [theme, setTheme] = useState<Theme>(savedTheme || 'light');

  const toggleTheme = () => {
    const newTheme = theme === 'light' ? 'dark' : 'light';
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);

    // Apply theme to document
    document.documentElement.setAttribute('data-theme', newTheme);
  };

  // Apply theme on initial render
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);

  // Memoize context value to prevent unnecessary re-renders
  const value = useMemo(() => ({ 
    theme, 
    toggleTheme 
  }), [theme]);

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

export const useTheme = (): ThemeContextType => {
  const context = useContext(ThemeContext);

  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }

  return context;
};

Now let’s create a theme toggle button component:

// ThemeToggle.tsx
import React from 'react';
import { useTheme } from './ThemeContext';

const ThemeToggle: React.FC = () => {
  const { theme, toggleTheme } = useTheme();

  return (
    <button 
      onClick={toggleTheme}
      aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
      className="theme-toggle"
    >
      {theme === 'light' ? '🌙' : '☀️'}
    </button>
  );
};

export default ThemeToggle;

And here’s how we’d apply this in our main app:

// App.tsx
import React from 'react';
import { ThemeProvider } from './ThemeContext';
import ThemeToggle from './ThemeToggle';
import HomePage from './HomePage';

const App: React.FC = () => {
  return (
    <ThemeProvider>
      <div className="app">
        <header>
          <h1>My American Store</h1>
          <ThemeToggle />
        </header>
        <HomePage />
      </div>
    </ThemeProvider>
  );
};

export default App;

Output:

Use Multiple contexts in TypeScript

This example demonstrates a common pattern used by American e-commerce websites and apps where users can toggle between light and dark modes, with their preference saved between sessions.

Troubleshooting Common useContext TypeScript Issues

1. Type ‘undefined’ is not assignable to type…

This error often occurs when your context might be undefined:

// The error
const { user } = useContext(UserContext); // Error: UserContext might be undefined

// The fix: Add a check or use a custom hook
const context = useContext(UserContext);
if (!context) throw new Error("UserContext must be used within a UserProvider");
const { user } = context;

2. Property ‘x’ does not exist on type ‘never’

This happens when TypeScript can’t infer the type correctly:

// The error
const [state, setState] = useState([]); // state is inferred as never[]

// The fix: Provide explicit type
const [state, setState] = useState<string[]>([]);

3. Cannot find name ‘React’

Ensure you’ve imported React and are using TypeScript’s JSX settings:

// In your tsconfig.json
{
  "compilerOptions": {
    "jsx": "react"
  }
}

Advanced useContext Patterns

Optimizing with Context Selectors

For large contexts, you can create selector hooks to only subscribe to parts of the context:

// AppContext.tsx
export const useUserName = () => {
  const { user } = useAppContext();
  return user?.name;
};

export const useCartCount = () => {
  const { cart } = useAppContext();
  return cart.items.length;
};

// Component only re-renders when the selected part changes
const UserGreeting = () => {
  const userName = useUserName();
  return <h1>Hello, {userName || 'Guest'}!</h1>;
};

Context with Reducers for Complex State

For complex state management, combine useContext with useReducer:

// ComplexContext.tsx
import React, { createContext, useContext, useReducer } from 'react';

interface State {
  loading: boolean;
  error: string | null;
  data: any;
}

type Action = 
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: any }
  | { type: 'FETCH_ERROR'; payload: string };

const initialState: State = {
  loading: false,
  error: null,
  data: null
};

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { loading: false, error: null, data: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
};

const DataContext = createContext<{
  state: State;
  dispatch: React.Dispatch<Action>;
} | undefined>(undefined);

export const DataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <DataContext.Provider value={{ state, dispatch }}>
      {children}
    </DataContext.Provider>
  );
};

export const useData = () => {
  const context = useContext(DataContext);
  if (!context) {
    throw new Error('useData must be used within a DataProvider');
  }
  return context;
};

By creating a context with TypeScript, we gain the benefits of type safety while maintaining the flexibility of React’s Context API. The useContext hook combined with TypeScript gives us confidence that our components are using the shared state correctly.

When building large-scale applications, this approach helps maintain code quality and prevents many common bugs before they even make it to the browser. I’ve found that a well-designed context architecture can significantly reduce the complexity of state management in React applications.

Whether you’re building a simple theme switcher or a complex shopping cart, TypeScript with useContext provides the tools you need to create maintainable and type-safe applications.


Title:: A Complete Guide

URL Slug:

51 Python Programs

51 PYTHON PROGRAMS PDF FREE

Download a FREE PDF (112 Pages) Containing 51 Useful Python Programs.

pyython developer roadmap

Aspiring to be a Python developer?

Download a FREE PDF on how to become a Python developer.

Let’s be friends

Be the first to know about sales and special discounts.