Skip to main content

Typing React Context in TypeScript: Complete Guide

React Context enables you to share state across components without prop drilling. When paired with TypeScript, Context becomes a type-safe way to distribute complex state and dispatch functions. The challenge is setting up the types correctly so that consumers know what the Context provides and when it's available.

Over years of maintaining shared state libraries, I found that untyped Context was a major friction point. Teams would forget to wrap components in a Provider, or try to access undefined context values. TypeScript eliminates these problems with a few type-safe patterns.

Creating a Typed Context

A Context holds a value of a specific type. You create it with React.createContext<T>, specifying the type T:

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

interface AuthContextValue {
user: User | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}

const AuthContext = React.createContext<AuthContextValue | undefined>(undefined);

Note that the Context type is AuthContextValue | undefined. This reflects the reality that the Context might not be initialized if a component is rendered outside a Provider. By including undefined, you force consumers to check whether the context exists before using it.

Provider with Initialization

Create a Provider component that wraps your Context and handles initialization:

interface AuthProviderProps {
children: React.ReactNode;
}

export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = React.useState<User | null>(null);
const [isLoading, setIsLoading] = React.useState(false);

const login = async (email: string, password: string) => {
setIsLoading(true);
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const userData = await response.json();
setUser(userData);
} finally {
setIsLoading(false);
}
};

const logout = () => {
setUser(null);
};

const value: AuthContextValue = {
user,
isLoading,
login,
logout,
};

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

By creating an explicit value object with the AuthContextValue type, TypeScript verifies that all required fields are present and correctly typed before passing to the Provider.

Creating a Custom Hook for Context Access

Instead of calling useContext directly in every component, create a custom hook that ensures the Context is available:

function useAuth(): AuthContextValue {
const context = React.useContext(AuthContext);

if (context === undefined) {
throw new Error('useAuth must be used within AuthProvider');
}

return context;
}

This hook enforces two contracts: (1) the Context is always provided when the hook is called, and (2) TypeScript narrows the return type to AuthContextValue (not undefined). Callers don't need to null-check; the hook guarantees a valid context.

Usage is now clean and type-safe:

function Profile() {
const { user, logout } = useAuth();

if (!user) {
return <div>Not logged in</div>;
}

return (
<div>
<p>Welcome, {user.name}</p>
<button onClick={logout}>Logout</button>
</div>
);
}

TypeScript knows user is User | null from the Context type, so the null check is enforced by the type system.

Multiple Contexts and Composition

For apps with many concerns (auth, theme, notifications), create separate Contexts and combine them in a single Provider component:

interface ThemeContextValue {
theme: 'light' | 'dark';
toggleTheme: () => void;
}

const ThemeContext = React.createContext<ThemeContextValue | undefined>(undefined);

function useTheme(): ThemeContextValue {
const context = React.useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}

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

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

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

// Combined provider
function AppProviders({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
);
}

// Root app
export function App() {
return (
<AppProviders>
<HomePage />
</AppProviders>
);
}

This layered approach keeps each Context focused and allows teams to add new contexts without modifying existing code.

Context with Complex State

For Contexts that hold complex state or dispatch actions, integrate with useReducer:

interface NotificationContextValue {
notifications: Notification[];
addNotification: (message: string, type: 'info' | 'error' | 'success') => void;
removeNotification: (id: string) => void;
}

interface Notification {
id: string;
message: string;
type: 'info' | 'error' | 'success';
}

type NotificationAction =
| { type: 'ADD'; message: string; notificationType: 'info' | 'error' | 'success' }
| { type: 'REMOVE'; id: string };

function notificationReducer(
state: Notification[],
action: NotificationAction
): Notification[] {
switch (action.type) {
case 'ADD':
return [
...state,
{
id: Math.random().toString(36).substr(2, 9),
message: action.message,
type: action.notificationType,
},
];
case 'REMOVE':
return state.filter((n) => n.id !== action.id);
default:
const _exhaustive: never = action;
return _exhaustive;
}
}

const NotificationContext = React.createContext<
NotificationContextValue | undefined
>(undefined);

function NotificationProvider({ children }: { children: React.ReactNode }) {
const [notifications, dispatch] = React.useReducer(notificationReducer, []);

const addNotification = (message: string, type: 'info' | 'error' | 'success') => {
dispatch({ type: 'ADD', message, notificationType: type });
};

const removeNotification = (id: string) => {
dispatch({ type: 'REMOVE', id });
};

const value: NotificationContextValue = {
notifications,
addNotification,
removeNotification,
};

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

function useNotifications(): NotificationContextValue {
const context = React.useContext(NotificationContext);
if (context === undefined) {
throw new Error('useNotifications must be used within NotificationProvider');
}
return context;
}

Combining Context with useReducer scales well for complex state. The reducer enforces state transitions, and the Context distributes them globally.

Key Takeaways

  • Define a Context type interface that includes all values and functions the Context provides.
  • Always make the Context type T | undefined to account for missing Providers.
  • Create a custom hook (e.g., useAuth) that throws if the Context is undefined, narrowing the type for consumers.
  • For multiple Contexts, compose them in a single Provider wrapper (e.g., AppProviders).
  • Pair Context with useReducer for complex state management that scales across the app.
  • Type the Context value object explicitly before passing to Provider to catch missing fields at compile time.

Frequently Asked Questions

Why should I make the Context type include undefined?

Because a component can be rendered outside a Provider. By including undefined, you force developers to either wrap their app properly or use a custom hook that throws (preferred). This catches missing Providers at runtime rather than leaving them silent.

Can I change the Context value dynamically?

Yes—the Provider's value is updated whenever its state changes. All consumers using useContext will re-render with the new value. To prevent unnecessary re-renders, memoize the value object using useMemo.

How do I avoid re-rendering all Context consumers when the value changes?

Memoize the value object with useMemo, or split Contexts by frequency of update (e.g., user data in one Context, UI state in another). Consumers only re-render if their specific Context value changes.

Is Context suitable for frequently changing state?

Not ideal. Context causes all consumers to re-render when any field changes. For high-frequency updates (form inputs, animations), use local state or a state management library like Redux or Zustand.

Further Reading