AiTechWorlds
AiTechWorlds
Prop drilling is the problem Context solves. When state needs to reach deeply nested components, passing it through every intermediate component is messy and fragile. Context lets you beam data directly to any component that needs it, without wiring it through the whole tree.
// App has the user. ProductDetailPage needs the user. But it has to pass through everything:
function App() {
const user = { name: "Alice", role: "admin" };
return <Layout user={user} />;
}
function Layout({ user }: { user: User }) {
return <Sidebar user={user} />; // Sidebar doesn't use user, just passes it
}
function Sidebar({ user }: { user: User }) {
return <UserMenu user={user} />; // UserMenu doesn't use it either
}
function UserMenu({ user }: { user: User }) {
return <p>Hello, {user.name}</p>; // Finally — used here
}
Every component in the chain must accept and forward user even though they don't care about it. Context eliminates this.
// src/context/auth.tsx
import { createContext, useContext, useState, ReactNode } from "react";
interface User {
id: string;
name: string;
email: string;
role: "user" | "admin";
}
interface AuthContextValue {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
}
// 1. Create the context with a sensible default (or null)
const AuthContext = createContext<AuthContextValue | null>(null);
// 2. Create the provider component
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
async function login(email: string, password: string) {
setIsLoading(true);
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const userData = await response.json();
setUser(userData);
} finally {
setIsLoading(false);
}
}
function logout() {
setUser(null);
fetch("/api/auth/logout", { method: "POST" });
}
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
}
// 3. Custom hook for clean access (with error handling)
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used inside an AuthProvider");
}
return context;
}
// Wrap your app with the provider
// src/main.tsx or src/app/layout.tsx
<AuthProvider>
<App />
</AuthProvider>
// Any component, anywhere in the tree
function UserMenu() {
const { user, logout } = useAuth(); // No prop drilling needed
return (
<div>
<p>Hello, {user?.name}</p>
<button onClick={logout}>Log out</button>
</div>
);
}
Use separate contexts for separate concerns — don't put everything in one giant context:
// Auth context
<AuthProvider>
// Theme context
<ThemeProvider>
// Cart context
<CartProvider>
<App />
</CartProvider>
</ThemeProvider>
</AuthProvider>
// src/context/theme.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
type Theme = "light" | "dark" | "system";
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: "light" | "dark";
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem("theme") as Theme) ?? "system";
});
const resolvedTheme: "light" | "dark" =
theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
: theme;
useEffect(() => {
localStorage.setItem("theme", theme);
document.documentElement.classList.toggle("dark", resolvedTheme === "dark");
}, [theme, resolvedTheme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error("useTheme must be used inside ThemeProvider");
return context;
}
Every component consuming a context re-renders when the context value changes. If your context value is an object created inline, it changes on every parent render:
// ❌ New object every render = all consumers re-render every time
<MyContext.Provider value={{ user, setUser }}>
// ✅ Memoize the value to prevent unnecessary re-renders
const value = useMemo(() => ({ user, setUser }), [user]);
<MyContext.Provider value={value}>
Or split the context: one for data (changes frequently), one for actions (stable functions):
const UserContext = createContext<User | null>(null);
const UserActionsContext = createContext<{ setUser: (u: User) => void } | null>(null);
// Components that only need actions don't re-render when user data changes
For complex state, combine context with useReducer (covered in the next lesson):
interface CartState {
items: CartItem[];
}
type CartAction =
| { type: "ADD_ITEM"; item: CartItem }
| { type: "REMOVE_ITEM"; id: string }
| { type: "CLEAR" };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "ADD_ITEM":
return { items: [...state.items, action.item] };
case "REMOVE_ITEM":
return { items: state.items.filter(i => i.id !== action.id) };
case "CLEAR":
return { items: [] };
}
}
const CartContext = createContext<{ state: CartState; dispatch: React.Dispatch<CartAction> } | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
Context is not a replacement for all state. Over-using it leads to unnecessary re-renders and complex debugging.
Use context for:
Use local state for:
Next lesson: useReducer for complex state — managing state machines and multi-action state.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises