React Context Patterns: Performance, Compound Components, and When to Use Zustand
Master React Context patterns: avoid unnecessary re-renders with context splitting, build compound components with implicit context, and know when to reach for Zustand instead of Context for global state.
React Context is both overused and misused. Developers reach for it to solve prop drilling, then discover it causes the entire component tree to re-render when any value changes. The fix โ splitting contexts, using useMemo, restructuring providers โ is the pattern this post covers, alongside the two legitimate use cases where Context shines: compound components and stable config/theme values.
For genuinely complex client state โ shopping carts, multi-step form data, real-time collaboration state โ Context is the wrong tool. Zustand is right. This post covers the decision clearly.
The Re-render Problem
// โ Classic mistake: one context for everything
const AppContext = createContext<{
user: User | null;
cart: CartItem[];
theme: 'light' | 'dark';
notifications: Notification[];
setUser: (user: User) => void;
addToCart: (item: CartItem) => void;
// ... 10 more setters
}>({} as any);
// Any change to ANY of these values re-renders EVERY consumer
// โ Cart update re-renders user profile header
// โ Notification badge re-renders checkout form
The root issue: React Context does not have selector support. Every consumer re-renders on every value change, regardless of which part of the context changed.
1. Context Splitting
The fix is simple: one context per concern.
// src/contexts/UserContext.tsx
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
interface UserContextValue {
user: User | null;
setUser: (user: User | null) => void;
logout: () => void;
}
const UserContext = createContext<UserContextValue | null>(null);
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUserState] = useState<User | null>(null);
const setUser = useCallback((u: User | null) => {
setUserState(u);
}, []);
const logout = useCallback(() => {
setUserState(null);
localStorage.removeItem('session');
}, []);
// useMemo prevents re-renders when UserProvider itself re-renders
// but the value hasn't changed
const value = useMemo(
() => ({ user, setUser, logout }),
[user, setUser, logout]
);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
export function useUser(): UserContextValue {
const ctx = useContext(UserContext);
if (!ctx) throw new Error('useUser must be used within UserProvider');
return ctx;
}
// src/contexts/CartContext.tsx โ separate context, separate re-render scope
interface CartContextValue {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: number;
}
const CartContext = createContext<CartContextValue | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const addItem = useCallback((item: CartItem) => {
setItems((prev) => {
const existing = prev.find((i) => i.id === item.id);
if (existing) {
return prev.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i
);
}
return [...prev, item];
});
}, []);
const removeItem = useCallback((id: string) => {
setItems((prev) => prev.filter((i) => i.id !== id));
}, []);
const total = useMemo(
() => items.reduce((sum, i) => sum + i.price * i.quantity, 0),
[items]
);
const value = useMemo(
() => ({ items, addItem, removeItem, total }),
[items, addItem, removeItem, total]
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCart(): CartContextValue {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart must be used within CartProvider');
return ctx;
}
Layered Provider Composition
// src/providers/AppProviders.tsx
export function AppProviders({ children }: { children: ReactNode }) {
return (
<UserProvider>
<CartProvider>
<ThemeProvider>
<NotificationProvider>
{children}
</NotificationProvider>
</ThemeProvider>
</CartProvider>
</UserProvider>
);
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
2. Context with useReducer (Complex Local State)
For components with complex update logic (wizard forms, accordion groups, drag-and-drop), useReducer + Context is cleaner than multiple useState calls.
// src/components/MultiStepForm/context.tsx
interface FormState {
step: number;
totalSteps: number;
data: {
personal?: PersonalData;
billing?: BillingData;
confirmation?: ConfirmationData;
};
errors: Record<string, string>;
isSubmitting: boolean;
}
type FormAction =
| { type: 'NEXT_STEP' }
| { type: 'PREV_STEP' }
| { type: 'SET_STEP'; payload: number }
| { type: 'UPDATE_DATA'; payload: Partial<FormState['data']> }
| { type: 'SET_ERRORS'; payload: Record<string, string> }
| { type: 'CLEAR_ERRORS' }
| { type: 'SET_SUBMITTING'; payload: boolean }
| { type: 'RESET' };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'NEXT_STEP':
return { ...state, step: Math.min(state.step + 1, state.totalSteps - 1) };
case 'PREV_STEP':
return { ...state, step: Math.max(state.step - 1, 0) };
case 'SET_STEP':
return { ...state, step: action.payload };
case 'UPDATE_DATA':
return { ...state, data: { ...state.data, ...action.payload } };
case 'SET_ERRORS':
return { ...state, errors: action.payload };
case 'CLEAR_ERRORS':
return { ...state, errors: {} };
case 'SET_SUBMITTING':
return { ...state, isSubmitting: action.payload };
case 'RESET':
return initialFormState;
default:
return state;
}
}
const initialFormState: FormState = {
step: 0,
totalSteps: 3,
data: {},
errors: {},
isSubmitting: false,
};
// Split state and dispatch into separate contexts
// โ Components that only dispatch won't re-render when state changes
const FormStateContext = createContext<FormState | null>(null);
const FormDispatchContext = createContext<React.Dispatch<FormAction> | null>(null);
export function FormProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(formReducer, initialFormState);
return (
<FormStateContext.Provider value={state}>
<FormDispatchContext.Provider value={dispatch}>
{children}
</FormDispatchContext.Provider>
</FormStateContext.Provider>
);
}
export function useFormState(): FormState {
const ctx = useContext(FormStateContext);
if (!ctx) throw new Error('useFormState must be within FormProvider');
return ctx;
}
export function useFormDispatch(): React.Dispatch<FormAction> {
const ctx = useContext(FormDispatchContext);
if (!ctx) throw new Error('useFormDispatch must be within FormProvider');
return ctx;
}
3. Compound Components Pattern
Compound components use implicit Context to share state between related components without prop drilling.
// src/components/Tabs/index.tsx
interface TabsContextValue {
activeTab: string;
setActiveTab: (id: string) => void;
variant: 'underline' | 'pills' | 'bordered';
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tab components must be used within <Tabs>');
return ctx;
}
// Root component owns state
interface TabsProps {
defaultTab: string;
onChange?: (tab: string) => void;
variant?: TabsContextValue['variant'];
children: ReactNode;
}
function Tabs({ defaultTab, onChange, variant = 'underline', children }: TabsProps) {
const [activeTab, setActiveTabState] = useState(defaultTab);
const setActiveTab = useCallback((id: string) => {
setActiveTabState(id);
onChange?.(id);
}, [onChange]);
const value = useMemo(
() => ({ activeTab, setActiveTab, variant }),
[activeTab, setActiveTab, variant]
);
return (
<TabsContext.Provider value={value}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// Tab list container
function TabList({ children }: { children: ReactNode }) {
const { variant } = useTabs();
return (
<div role="tablist" className={`tab-list tab-list--${variant}`}>
{children}
</div>
);
}
// Individual tab trigger
interface TabProps {
id: string;
children: ReactNode;
disabled?: boolean;
}
function Tab({ id, children, disabled = false }: TabProps) {
const { activeTab, setActiveTab } = useTabs();
const isActive = activeTab === id;
return (
<button
role="tab"
aria-selected={isActive}
aria-controls={`panel-${id}`}
id={`tab-${id}`}
disabled={disabled}
onClick={() => !disabled && setActiveTab(id)}
className={`tab ${isActive ? 'tab--active' : ''} ${disabled ? 'tab--disabled' : ''}`}
>
{children}
</button>
);
}
// Tab panel content
interface TabPanelProps {
id: string;
children: ReactNode;
}
function TabPanel({ id, children }: TabPanelProps) {
const { activeTab } = useTabs();
const isActive = activeTab === id;
return (
<div
role="tabpanel"
id={`panel-${id}`}
aria-labelledby={`tab-${id}`}
hidden={!isActive}
className="tab-panel"
>
{isActive && children}
</div>
);
}
// Attach sub-components to Tabs
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
export { Tabs };
// Usage โ clean API, no prop passing:
// <Tabs defaultTab="overview" variant="pills">
// <Tabs.List>
// <Tabs.Tab id="overview">Overview</Tabs.Tab>
// <Tabs.Tab id="settings">Settings</Tabs.Tab>
// </Tabs.List>
// <Tabs.Panel id="overview"><OverviewContent /></Tabs.Panel>
// <Tabs.Panel id="settings"><SettingsContent /></Tabs.Panel>
// </Tabs>
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
4. Stable Context (Theme, Config, Feature Flags)
Context is ideal for values that almost never change โ theme, locale, feature flags, auth config. These don't trigger the re-render problem because they're updated rarely.
// src/contexts/FeatureFlagContext.tsx
interface FeatureFlags {
newDashboard: boolean;
betaCheckout: boolean;
aiAssistant: boolean;
}
const FeatureFlagContext = createContext<FeatureFlags>({
newDashboard: false,
betaCheckout: false,
aiAssistant: false,
});
export function FeatureFlagProvider({
flags,
children,
}: {
flags: FeatureFlags;
children: ReactNode;
}) {
// Memoize: only re-render if flags object reference changes
const stableFlags = useMemo(() => flags, [
flags.newDashboard,
flags.betaCheckout,
flags.aiAssistant,
]);
return (
<FeatureFlagContext.Provider value={stableFlags}>
{children}
</FeatureFlagContext.Provider>
);
}
export function useFeatureFlag(flag: keyof FeatureFlags): boolean {
return useContext(FeatureFlagContext)[flag];
}
// Usage: conditional rendering without prop drilling
// const showAI = useFeatureFlag('aiAssistant');
5. When to Use Zustand Instead
Context re-renders all consumers. Zustand uses subscriptions โ components only re-render when the specific slice they subscribe to changes.
Use Context when:
- Values are stable (theme, config, feature flags)
- Compound component internal state
- Small local component trees
- Next.js server-side data passing (HydrationBoundary)
Use Zustand when:
- Frequent updates (cart, real-time data, UI state that many components read)
- Need selectors (subscribe to only part of state)
- State that spans routes/pages
- State with complex actions (async, side effects)
// src/stores/cart.store.ts โ Zustand alternative to CartContext
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clear: () => void;
total: () => number;
}
export const useCartStore = create<CartStore>()(
persist(
immer((set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
existing.quantity += item.quantity;
} else {
state.items.push(item);
}
}),
removeItem: (id) =>
set((state) => {
state.items = state.items.filter((i) => i.id !== id);
}),
updateQuantity: (id, quantity) =>
set((state) => {
const item = state.items.find((i) => i.id === id);
if (item) item.quantity = Math.max(0, quantity);
}),
clear: () => set({ items: [] }),
total: () =>
get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
})),
{
name: 'cart-storage',
storage: createJSONStorage(() => localStorage),
}
)
);
// Selector: only re-renders when item count changes (not on price changes)
export const useCartItemCount = () =>
useCartStore((state) => state.items.reduce((sum, i) => sum + i.quantity, 0));
Decision Chart
Is the data stable (rarely changes)?
YES โ Context (theme, config, feature flags)
NO โ Continue...
Is it component-internal state shared between sub-components?
YES โ Context with compound component pattern
NO โ Continue...
Do multiple unrelated components need it?
YES โ Zustand (subscriptions = no over-rendering)
NO โ Continue...
Is it server data?
YES โ React Query (caching, background refetch, deduplication)
NO โ useState or useReducer (keep it local)
See Also
- React Query Server State: useQuery, Optimistic Updates, and Infinite Scroll
- React Performance Optimization: Memo, Virtualization, and Code Splitting
- React Suspense Patterns for App Router
- Next.js App Router Patterns: Server Components and Server Actions
- Next.js Testing Strategy: Unit, Integration, and E2E with Playwright
Working With Viprasol
Building a React application that's suffering from prop drilling or re-render cascades? We audit component trees, redesign state architecture โ Context where it belongs, Zustand where it performs, React Query for server data โ and implement compound component APIs that make your codebase a pleasure to extend.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.