How to Use React Context with useState in TypeScript: Best Practices for Global State Management
Use a generic context factory to separate state and dispatch contexts, memoize provider values with useMemo, and scope providers to specific component trees to prevent unnecessary re-renders when managing global state with React Context and TypeScript.
Managing global state in React applications often leads to prop drilling or overly complex external libraries. Using React Context with useState in TypeScript provides a lightweight, type-safe alternative for shared state, as demonstrated in the facebook/react repository's compiler playground implementation.
Choose the Right State Management Abstraction
Selecting the appropriate API depends on your state complexity and update frequency.
Simple Read-Only Values
For static data like themes or configuration objects, use React.createContext<T> with a default value. This requires no dispatcher since consumers only read the value.
Mutable Global State with Type Safety
For mutable global state—such as authentication status or UI settings—implement a custom wrapper that returns separate Provider, useContext, and useDispatch hooks. The facebook/react compiler playground uses this pattern in compiler/apps/playground/lib/createContext.ts to mirror the Redux pattern without external dependencies while maintaining full TypeScript type safety.
Complex State Logic
When state transitions involve multiple actions or depend on previous state, replace useState with useReducer. This provides a stable dispatch function reference and makes state transitions explicit, as implemented in compiler/apps/playground/components/StoreContext.tsx.
Stabilize Context Values to Prevent Re-renders
React re-renders all consumers whenever a <Context.Provider>'s value prop changes by reference. To optimize performance, memoize the context value.
In compiler/apps/playground/components/StoreContext.tsx, the provider uses useMemo to ensure the context value only changes when state or dispatch actually updates:
import React, {useReducer, useMemo, ReactNode} from 'react';
type Store = {
isPageReady: boolean;
};
type Action = {type: 'SET_READY'; payload: boolean};
const reducer = (state: Store, action: Action): Store => {
switch (action.type) {
case 'SET_READY':
return {...state, isPageReady: action.payload};
default:
return state;
}
};
const StoreContext = React.createContext<Store | undefined>(undefined);
const StoreDispatchContext = React.createContext<React.Dispatch<Action> | undefined>(undefined);
export const StoreProvider = ({children}: {children: ReactNode}) => {
const [state, dispatch] = useReducer(reducer, {isPageReady: false});
const memoState = useMemo(() => state, [state]);
return (
<StoreContext.Provider value={memoState}>
<StoreDispatchContext.Provider value={dispatch}>
{children}
</StoreDispatchContext.Provider>
</StoreContext.Provider>
);
};
This pattern ensures that components such as Header only re-render when the specific slice of state they consume changes.
Separate State and Dispatch Contexts
To prevent components that only dispatch actions from re-rendering when state updates, split your context into two separate contexts: one for state and one for dispatch.
The createContext factory in compiler/apps/playground/lib/createContext.ts implements this separation:
import * as React from 'react';
export default function createContext<T>() {
const StateContext = React.createContext<T | undefined>(undefined);
const DispatchContext = React.createContext<React.Dispatch<any> | undefined>(undefined);
const Provider = ({
children,
value,
}: {children: React.ReactNode; value: [T, React.Dispatch<any>]}) => {
const [state, dispatch] = value;
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
};
const useContext = () => {
const ctx = React.useContext(StateContext);
if (ctx === undefined) {
throw new Error('useContext must be used within a Provider');
}
return ctx;
};
const useDispatch = () => {
const ctx = React.useContext(DispatchContext);
if (ctx === undefined) {
throw new Error('useDispatch must be used within a Provider');
}
return ctx;
};
return {Provider, useContext, useDispatch};
}
This architecture allows Header components to subscribe only to state changes while action-dispatching buttons remain stable across renders.
Implement Type-Safe Generic Contexts
TypeScript generics ensure that your context consumers receive correctly typed data without casting. Define your context using a generic factory function that captures the store shape:
type Store = {
user: User | null;
theme: 'light' | 'dark';
};
const StoreContext = createContext<Store>();
When consuming the context in compiler/apps/playground/components/Header.tsx, the hooks return fully typed values:
import React from 'react';
import {useStore, useStoreDispatch} from './StoreContext';
export const Header = () => {
const {isPageReady} = useStore();
const dispatch = useStoreDispatch();
const toggleReady = () => {
dispatch({type: 'SET_READY', payload: !isPageReady});
};
return (
<header>
<h1>Playground</h1>
<button onClick={toggleReady}>
{isPageReady ? 'Reset' : 'Start'}
</button>
</header>
);
};
The TypeScript compiler validates that isPageReady exists on the store and that dispatched actions match the Action type definition.
Scope Context to Component Trees
Not all state requires global exposure. If a state slice is only relevant to a specific subtree—such as editor configuration or form state—colocate the provider with that subtree rather than placing it at the application root.
In compiler/apps/playground/components/Editor/ConfigEditor.tsx, local UI state is managed with useState while global store interactions use the context provider:
import React, {useState} from 'react';
import {useStore} from '../StoreContext';
export const ConfigEditor = () => {
// Local UI state - does not trigger global re-renders
const [isExpanded, setIsExpanded] = useState<boolean>(false);
// Global state from context
const {isPageReady} = useStore();
return (
<div>
<button onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? 'Collapse' : 'Expand'} Settings
</button>
{isExpanded && <div>Configuration options here...</div>}
<p>Page ready: {isPageReady ? 'Yes' : 'No'}</p>
</div>
);
};
This approach keeps the global context lean and minimizes the number of components that re-render when global state changes.
Summary
- Use a generic context factory like the one in
compiler/apps/playground/lib/createContext.tsto generate type-safeProvider,useContext, anduseDispatchhooks. - Memoize context values with
useMemoin your provider to prevent unnecessary consumer re-renders when the provider component updates. - Split state and dispatch into separate contexts to allow components that only dispatch actions to remain stable across state updates.
- Leverage TypeScript generics to ensure compile-time type safety for your store shape and action types.
- Scope providers to subtrees rather than always using global providers, and prefer
useStatefor local UI concerns that do not require global access.
Frequently Asked Questions
Should I use useState or useReducer with React Context in TypeScript?
Use useState for simple primitive values or objects that update together, such as theme strings or boolean flags. Use useReducer when state transitions involve complex logic, multiple actions, or when you need a stable dispatch reference to prevent child components from re-rendering, as implemented in compiler/apps/playground/components/StoreContext.tsx.
How do I prevent unnecessary re-renders when using React Context with useState?
Wrap your context value in useMemo to maintain referential equality between renders unless the underlying state actually changes. Additionally, split your context into separate state and dispatch contexts so that components subscribing only to the dispatcher do not re-render when state updates, following the pattern in compiler/apps/playground/lib/createContext.ts.
Can I use React Context instead of Redux for global state management in TypeScript?
Yes, React Context combined with useReducer and a generic context factory provides a type-safe global state solution without external dependencies. The facebook/react compiler playground demonstrates this architecture by exporting typed useStore and useStoreDispatch hooks that offer Redux-like patterns while maintaining compile-time type safety through TypeScript generics.
How do I make React Context type-safe with TypeScript?
Implement a generic context factory function that accepts a type parameter <T> and returns typed Provider, useContext, and useDispatch hooks. This pattern, found in compiler/apps/playground/lib/createContext.ts, ensures that consumers receive correctly typed state and dispatch functions without manual type casting or any annotations.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →