How to Share Data Between Components Using the Context API
Advertisement
🧩 Scenario
Architecture Walkthrough
createContext and the Null Default
createContext accepts a default value used only when a consumer component has no Provider above it in the tree. For contexts that always require a Provider (authentication, theme), passing null as the default and narrowing the type forces consumers to acknowledge the possibility of being outside a provider. A custom hook can catch this case and throw an error with a helpful message.
Using an empty object {} as the default silently returns an empty context to components outside a provider, which leads to subtle runtime errors. null makes the error explicit.
Provider Pattern
The Provider component wraps a subtree and supplies a value. Best practice is to co-locate the state management with the Provider so the context file is self-contained. The Provider uses useState or useReducer to manage the data, exposes both the data and the actions (setters, dispatch) through the context value, and wraps the value in useMemo to prevent a new object reference on every Provider render.
Custom Hook for Safe Consumption
Wrapping useContext in a custom hook named useSomething serves three purposes. It guards against usage outside the Provider with a clear error. It gives consumers a clean API without needing to import both useContext and the context object. And it makes the consuming code easier to test by mocking the hook rather than wrapping every test in a Provider.
Key Code Explained
// auth-context.tsx
interface User {
id: string;
name: string;
email: string;
}
interface AuthContextValue {
user: User | null;
isAuthenticated: boolean;
signIn: (email: string, password: string) => Promise<void>;
signOut: () => void;
}
// null default: forces usage inside AuthProvider
const AuthContext = createContext<AuthContextValue | null>(null);
// Custom hook: guards against usage outside provider
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be called within an AuthProvider');
}
return ctx;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const signIn = useCallback(async (email: string, password: string) => {
const userData = await loginApi(email, password);
setUser(userData);
}, []);
const signOut = useCallback(() => {
setUser(null);
clearSession();
}, []);
// useMemo: prevents new object reference on every AuthProvider render
const value = useMemo<AuthContextValue>(
() => ({
user,
isAuthenticated: user !== null,
signIn,
signOut,
}),
[user, signIn, signOut],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// App setup
function App() {
return (
<AuthProvider>
<Router>
<AppRoutes />
</Router>
</AuthProvider>
);
}
// Consumer: clean ergonomics, no context import needed
function NavBar() {
const { user, isAuthenticated, signOut } = useAuth();
return (
<nav>
{isAuthenticated ? (
<>
<span>{user!.name}</span>
<button onClick={signOut}>Sign Out</button>
</>
) : (
<a href="/login">Sign In</a>
)}
</nav>
);
}
// Splitting contexts for performance
// If theme changes rarely and user session changes separately:
function AppProviders({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
);
}
// Components reading theme do not re-render when user session changes
// Components reading auth do not re-render when theme changes
The isAuthenticated: user !== null field in the context value is an example of derived state that belongs in the context value object. Computing it once in the Provider means consumers do not need to repeat user !== null everywhere. Because isAuthenticated is derived from user, it updates automatically when user changes, with no extra state or effect needed.
Tradeoffs
| Context value pattern | Re-render behavior | TypeScript safety |
|---|---|---|
| Object without useMemo | Re-renders all consumers on every render | Depends on type |
| useMemo on value | Only re-renders when deps change | Same |
| Split contexts | Each context re-renders only its consumers | Requires more files |
What Interviewers Actually Check
- Whether you know to pass
nullas the default and check for it in a custom hook - Whether you wrap the context value in
useMemoto prevent unnecessary re-renders - Whether you write a custom hook rather than calling
useContextdirectly in consumers - Whether you know that all consumers re-render on every context value change
- Whether you can explain when splitting contexts is the right mitigation
Follow-Up Questions
- How would you provide a mock
AuthProviderin tests that returns a specific user without network requests? - How does
React.lazyandSuspenseinteract with context: does a lazily loaded component that callsuseAuthstill have access to the Provider's value? - How would you implement a multi-tenant context that provides different values based on a URL path segment?
- What is the
useContextSelectorlibrary and how does it solve the per-field re-render granularity that built-inuseContextlacks? - How would you combine
useReducerwith Context to implement a Redux-like dispatch pattern without Redux?
Common Candidate Mistakes
- Calling
useContext(AuthContext)directly in components without a guard, gettingnulland a confusing error when used outside the provider - Not wrapping the context value in
useMemo, causing all consumers to re-render on every parent re-render even when data is unchanged - Using a single context for all global state, causing all consumers to re-render when any part of the state changes
- Using an empty object
{}as thecreateContextdefault, silently returning nothing to components outside the provider - Not providing a TypeScript type for the context value, losing autocomplete and type safety in consumers
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you create a typed context with a null default value and a type union?
- Can you build a Provider that manages state and exposes typed actions?
- Can you write a custom hook that reads context and throws a descriptive error if called outside the provider?
- Can you explain why
useMemoon the context value prevents unnecessary consumer re-renders? - Can you describe the re-render behavior and how splitting contexts mitigates it?
Summary
The Context API shares values across a component tree without prop drilling. The production-quality implementation has three parts: a context created with createContext and a null default, a Provider component that manages state and wraps the value in useMemo, and a custom hook that reads the context and throws a helpful error if called outside the Provider.
useMemo on the context value object is essential because context comparison uses reference equality. Without it, the Provider creates a new object on every render, causing all consumers to re-render even when the data has not changed. With useMemo, consumers only re-render when the actual data in the value changes.
All consumers re-render when any field in the context value changes. For contexts that hold multiple pieces of state with different update frequencies, splitting into separate contexts prevents cross-concern re-renders. A ThemeContext that changes rarely does not cause NavBar re-renders when a CartContext that updates frequently receives a new item.
When should I use Context instead of props?
When data needs to be accessed by multiple components at different depths and prop drilling would require passing through many intermediate components that do not use the value themselves.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement