React State Management - Redux and Zustand
Redux, Zustand, and other state management libraries
Question 2: Context Performance Optimizationβ
Difficulty: π΄ Hard Frequency: ββββ Time: 10 minutes Companies: Meta, Netflix, Performance-critical apps
Questionβ
What are the performance pitfalls of React Context and how do you optimize it?
Answerβ
Context Performance Issue - Every context value change re-renders ALL consumers, even if they only use part of the value.
Code Exampleβ
// β BAD: Single context with multiple values
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
// New object on every render!
const value = { user, setUser, theme, setTheme, notifications, setNotifications };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
// Problem: Changing theme re-renders ALL components using AppContext
// β
SOLUTION 1: Split contexts
const UserContext = createContext();
const ThemeContext = createContext();
const NotificationContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
return (
<UserContext.Provider value={{ user, setUser }}>
<ThemeContext.Provider value={{ theme, setTheme }}>
<NotificationContext.Provider value={{ notifications, setNotifications }}>
{children}
</NotificationContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
}
// Now components only re-render when their specific context changes
// β
SOLUTION 2: Memoize context value
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(
() => ({ user, setUser }),
[user] // Only create new object when user changes
);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
// β
SOLUTION 3: Separate state and dispatch
const StateContext = createContext();
const DispatchContext = createContext();
function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
// Components that only dispatch don't re-render when state changes!
function Button() {
const dispatch = useContext(DispatchContext);
// No re-render when state changes
return <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>;
}
// β
SOLUTION 4: Selector pattern
function useAppSelector(selector) {
const state = useContext(StateContext);
return selector(state);
}
function UserName() {
// Only re-renders when user.name changes
const name = useAppSelector(state => state.user.name);
return <div>{name}</div>;
}
Resourcesβ
π Deep Diveβ
The Context Re-render Problem: Understanding the Mechanics
React Context triggers re-renders through a subscription mechanism that operates at the fiber tree level. When you call Provider.value = newValue, React doesn't perform any shallow comparison or equality check - it uses strict reference equality (Object.is) to determine if the value changed. If the reference differs, React marks all Context consumers in the fiber tree for re-rendering, regardless of whether they actually use the changed portion of the value.
Why This Happens - The Implementation Details:
React's Context implementation maintains a linked list of consumers for each context. When a Provider's value updates, React walks this consumer list and schedules updates for each one. This happens during the render phase, before reconciliation. The key issue is that React cannot know which properties of the context value a component actually uses - it treats the entire value as atomic. If you pass { user, theme, settings } and only theme changes, components consuming just user still re-render because the containing object reference changed.
The Object Reference Trap:
// This creates a new object on EVERY render
const value = { user, setUser, theme, setTheme };
// Even if user and theme haven't changed, this is a new object reference
// Result: ALL consumers re-render on every parent render
This is particularly insidious because React components re-render frequently - parent renders, state changes, props changes all trigger renders. Each render creates a new context value object, triggering cascading re-renders through the entire consumer tree.
Performance Impact Analysis:
In a typical application with 50-100 components consuming context:
- Without optimization: 50-100 component re-renders per context update
- With split contexts: 5-10 component re-renders (only relevant consumers)
- With memoization: 10-20 component re-renders (initial + actual dependencies)
- With selectors: 3-5 component re-renders (only components using changed data)
Advanced Optimization Techniques:
1. State/Dispatch Separation Pattern: This pattern leverages the fact that dispatch functions are stable (never change). By splitting state and dispatch into separate contexts, components that only dispatch actions never re-render when state changes. This is particularly powerful for form components, buttons, and action triggers.
// Dispatch context value never changes - dispatch function is stable
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
// Button component subscribes only to dispatch - never re-renders
const Button = () => {
const dispatch = useContext(DispatchContext); // Stable reference
return <button onClick={() => dispatch(action)}>Click</button>;
};
2. Selector Pattern with Bailout:
The selector pattern adds a layer of granular subscription. Instead of subscribing to the entire context value, components subscribe to derived slices of state. When combined with useSyncExternalStore (React 18+), you can implement bailout logic that prevents re-renders when the selected slice hasn't changed, even if the context value reference changed.
3. Context Composition Strategy: Organize contexts by update frequency and scope:
- Static contexts (rarely change): Theme configuration, feature flags
- Semi-static contexts (change occasionally): User authentication, permissions
- Dynamic contexts (frequent updates): UI state, notifications
- High-frequency contexts (multiple updates per second): Real-time data, animations
Place static contexts at the top of the tree, dynamic at the bottom. This minimizes re-render cascades when high-frequency contexts update.
Profiling Context Performance:
Use React DevTools Profiler to measure context impact:
// Add profiler around context provider
<Profiler id="AppContext" onRender={onRenderCallback}>
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
</Profiler>
function onRenderCallback(id, phase, actualDuration) {
console.log(`${id} ${phase} took ${actualDuration}ms`);
// Track re-render frequency and duration
}
Memory Considerations:
Multiple contexts increase memory overhead slightly (each context maintains its own consumer list), but this is negligible compared to the performance benefits. A typical context costs ~100 bytes plus the value size. Even with 10 contexts, total overhead is <1KB - far less than the cost of unnecessary re-renders.
React 18+ Improvements:
React 18 introduced automatic batching and improved context performance through:
- Batched context updates within the same event handler
- Improved fiber reconciliation for context consumers
- Better integration with Concurrent Rendering features
useSyncExternalStorefor building efficient selector patterns
The key insight is that context performance problems stem from React's inability to track fine-grained dependencies within context values. All optimization strategies work around this limitation by either reducing the scope of what changes (split contexts, memoization) or by adding explicit dependency tracking (selectors).
π Real-World Scenarioβ
Production Crisis: Context-Induced Performance Collapse at E-commerce Platform
Background:
A large e-commerce platform with 200,000 daily active users experienced severe performance degradation after implementing a unified global state management system using React Context. The application had 80+ components consuming a single AppContext containing user data, shopping cart, product filters, notifications, and UI state.
Problem Discovery:
Users reported the application feeling "sluggish" with noticeable lag when interacting with product filters, adding items to cart, or toggling UI elements. Performance monitoring showed concerning metrics:
Metrics Before Optimization:
- Page interaction delay: 800-1200ms (target: <100ms)
- Filter change render time: 450ms per filter update
- Add to cart action: 650ms from click to UI update
- Total components re-rendered per context update: 82 components
- JavaScript execution time: 320ms per context update
- Frame drops during interactions: 45-60% of frames >16.67ms (60fps threshold)
- Time to Interactive (TTI): 8.2 seconds (target: <3.5s)
- Lighthouse Performance Score: 42/100
Debugging Process:
Step 1: Enable React DevTools Profiler
// Added profiling to production build (with sampling)
import { Profiler } from 'react';
<Profiler id="AppContext" onRender={(id, phase, actualDuration, baseDuration, startTime, commitTime, interactions) => {
if (actualDuration > 16) { // Flag renders >1 frame
analytics.track('slow-render', {
id,
duration: actualDuration,
interactions: Array.from(interactions)
});
}
}}>
<AppContext.Provider value={appState}>
{children}
</AppContext.Provider>
</Profiler>
Step 2: Analyze Re-render Patterns Using React DevTools Profiler in production (0.1% sampling), the team discovered:
- Cart updates triggered 82 component re-renders (entire app)
- Filter changes re-rendered product list + header + sidebar + footer + cart
- Notification updates re-rendered everything, even unrelated product cards
- Context value was a new object on every parent component render
Step 3: Identify Root Cause
// The problematic implementation
function App() {
const [user, setUser] = useState(null);
const [cart, setCart] = useState([]);
const [filters, setFilters] = useState({});
const [notifications, setNotifications] = useState([]);
const [uiState, setUIState] = useState({});
// NEW OBJECT EVERY RENDER! β
const value = {
user, setUser,
cart, setCart,
filters, setFilters,
notifications, setNotifications,
uiState, setUIState
};
return (
<AppContext.Provider value={value}>
<Header /> {/* Re-renders on ANY change */}
<Sidebar /> {/* Re-renders on ANY change */}
<ProductList /> {/* Re-renders on ANY change */}
<Footer /> {/* Re-renders on ANY change */}
</AppContext.Provider>
);
}
Step 4: Measure Component Render Costs Added performance marks around expensive components:
function ProductList() {
performance.mark('ProductList-render-start');
const { filters } = useContext(AppContext);
// ... render logic
performance.mark('ProductList-render-end');
performance.measure('ProductList-render', 'ProductList-render-start', 'ProductList-render-end');
}
Results showed ProductList alone took 180ms to render, and it was rendering on every cart update, notification, etc.
Solution Implementation:
Phase 1: Context Splitting (Week 1)
// Split into domain-specific contexts
const UserContext = createContext();
const CartContext = createContext();
const FiltersContext = createContext();
const NotificationsContext = createContext();
const UIContext = createContext();
function AppProviders({ children }) {
const [user, setUser] = useState(null);
const [cart, setCart] = useState([]);
const [filters, setFilters] = useState({});
const [notifications, setNotifications] = useState([]);
const [uiState, setUIState] = useState({});
// Memoize each context value
const userValue = useMemo(() => ({ user, setUser }), [user]);
const cartValue = useMemo(() => ({ cart, setCart }), [cart]);
const filtersValue = useMemo(() => ({ filters, setFilters }), [filters]);
const notificationsValue = useMemo(() => ({ notifications, setNotifications }), [notifications]);
const uiValue = useMemo(() => ({ uiState, setUIState }), [uiState]);
return (
<UserContext.Provider value={userValue}>
<CartContext.Provider value={cartValue}>
<FiltersContext.Provider value={filtersValue}>
<NotificationsContext.Provider value={notificationsValue}>
<UIContext.Provider value={uiValue}>
{children}
</UIContext.Provider>
</NotificationsContext.Provider>
</FiltersContext.Provider>
</CartContext.Provider>
</UserContext.Provider>
);
}
Metrics After Phase 1 (Context Splitting):
- Filter change render time: 180ms (60% improvement)
- Add to cart action: 220ms (66% improvement)
- Components re-rendered per cart update: 12 components (85% reduction)
- Frame drops: 15-20% (67% improvement)
Phase 2: State/Dispatch Separation for Cart (Week 2)
const CartStateContext = createContext();
const CartDispatchContext = createContext();
function CartProvider({ children }) {
const [cart, dispatch] = useReducer(cartReducer, []);
return (
<CartStateContext.Provider value={cart}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
// AddToCart button no longer re-renders when cart changes!
function AddToCartButton({ productId }) {
const dispatch = useContext(CartDispatchContext);
return (
<button onClick={() => dispatch({ type: 'ADD_ITEM', productId })}>
Add to Cart
</button>
);
// This component never re-renders when cart updates
}
Metrics After Phase 2:
- Add to cart action: 95ms (85% total improvement)
- Product card re-renders on cart update: 0 (100% elimination)
Phase 3: Selector Pattern for Filters (Week 3)
// Custom hook with selector
function useFilterSelector(selector) {
const filters = useContext(FiltersContext);
return useMemo(() => selector(filters), [filters, selector]);
}
function ProductList() {
// Only re-renders when category filter changes, not price/brand/etc
const category = useFilterSelector(f => f.category);
return <div>{/* render products for category */}</div>;
}
Final Production Metrics (After All Optimizations):
- Page interaction delay: 85-120ms (90% improvement) β
- Filter change render time: 48ms (89% improvement) β
- Add to cart action: 95ms (85% improvement) β
- Total components re-rendered per context update: 3-8 components (90% reduction) β
- JavaScript execution time: 35ms per context update (89% improvement) β
- Frame drops: <5% (92% improvement) β
- Time to Interactive: 3.1 seconds (62% improvement) β
- Lighthouse Performance Score: 87/100 (107% improvement) β
Business Impact:
- Cart abandonment rate decreased 18% (faster, more responsive checkout)
- User engagement increased 23% (filters responded instantly)
- Mobile bounce rate decreased 31% (better performance on low-end devices)
- Revenue increase of $420,000/month attributed to improved UX
Lessons Learned:
- Never put unrelated state in a single context
- Always memoize context values with
useMemo - Separate state and dispatch for action-heavy components
- Use selectors for granular subscriptions
- Profile in production with sampling to catch real-world issues
- Context is fast enough when used correctly - no need for Redux if optimized properly
<details> <summary><strong>βοΈ Trade-offs: Context Optimization Strategies</strong></summary>
Context Optimization Strategy Comparison
Strategy 1: Split Contexts
Pros:
- Simplest to implement - just create more contexts
- No new dependencies or libraries needed
- Clear separation of concerns (each context has single responsibility)
- Excellent re-render prevention (only relevant consumers update)
- Easy to debug (clear which context caused re-render)
- TypeScript-friendly with specific context types
- Works perfectly for static/semi-static data (theme, auth, locale)
Cons:
- Increased provider nesting (Provider hell with 5+ contexts)
- More boilerplate code (each context needs provider, hook, types)
- Can make component tree harder to read with many providers
- Sharing logic between contexts requires additional abstraction
- Each context adds slight memory overhead (~100 bytes per context)
Performance Characteristics:
- Re-render reduction: 70-90% for unrelated updates
- Memory overhead: ~500 bytes for 5 contexts (negligible)
- Runtime cost: Zero - no selector evaluation needed
- Best for: Distinct domains (user, theme, settings, cart, notifications)
When to Use:
- You have 3+ unrelated pieces of global state
- Different parts of the app consume different state slices
- State updates have different frequencies
- Team prefers explicit, clear code over clever abstractions
Strategy 2: Memoize Context Value with useMemo
Pros:
- Minimal code change (wrap value in useMemo)
- Prevents re-renders from parent component updates
- Single context - no provider nesting
- Preserves object reference when dependencies haven't changed
- Works well with existing single-context architecture
Cons:
- Does NOT prevent re-renders when ANY dependency changes
- Still re-renders all consumers if any piece of state updates
- Easy to forget dependencies (stale closures)
- Adds runtime cost (memoization check on every render)
- False sense of optimization (only prevents parent re-render issues)
Performance Characteristics:
- Re-render prevention: Only prevents parent re-renders, not sibling state changes
- Memory overhead: ~50 bytes per memoized value
- Runtime cost: 0.01-0.05ms per render for dependency check
- Best for: Preventing parent component re-renders from propagating
When to Use:
- You have a single context with related state
- Main problem is parent component re-renders creating new objects
- All consumers genuinely need to update when any state changes
- Simple applications with <20 components consuming context
Decision Matrix:
Problem: Parent re-renders β Use: useMemo β
Problem: Unrelated state changes β Use: Split Contexts β
Problem: Both issues β Use: Split Contexts + useMemo β
Strategy 3: State/Dispatch Separation
Pros:
- Components that only dispatch NEVER re-render on state changes
- Dispatch function is stable (never changes reference)
- Perfect for form inputs, buttons, action triggers
- Scales extremely well (thousands of action components)
- Follows Flux/Redux pattern (familiar to many developers)
- Enables middleware, logging, time-travel debugging
Cons:
- Requires learning useReducer pattern
- More complex mental model (actions, reducers)
- Components need access to both contexts for read+write
- Dispatch-only components still need state for conditional rendering
- Overkill for simple state (single boolean, string, etc.)
Performance Characteristics:
- Re-render elimination: 100% for dispatch-only components
- Memory overhead: ~200 bytes (two contexts)
- Runtime cost: Reducer execution overhead (~0.1-0.5ms)
- Best for: Action-heavy UIs (forms, buttons, toolbars)
When to Use:
- You have many components that only trigger actions (buttons, forms)
- State updates follow predictable patterns (suitable for reducer)
- You need centralized state update logic
- Application will eventually need Redux-like features
Strategy 4: Selector Pattern
Pros:
- Granular re-render control (only when selected data changes)
- Works with any context structure
- Enables computed/derived state without extra re-renders
- Can implement equality checks (shallow, deep, custom)
- Most flexible approach - handles complex use cases
- Similar to Redux useSelector (familiar API)
Cons:
- Most complex to implement correctly
- Requires additional library or custom hook
- Selector function must be stable (useCallback) or cause re-renders
- Equality comparison adds runtime cost
- Easy to create bugs with incorrect selectors
- Debugging is harder (why didn't it re-render?)
Performance Characteristics:
- Re-render prevention: 80-95% for fine-grained selections
- Memory overhead: ~100 bytes per selector + closure
- Runtime cost: Selector execution (0.05-0.2ms) + equality check (0.01-0.1ms)
- Best for: Large state objects with frequent updates to small portions
When to Use:
- Context value is large with many properties
- Components only use 1-2 properties from context
- Frequent updates to different parts of state
- You're already comfortable with Redux patterns
Implementation Example:
// Custom selector with shallow equality
function useContextSelector(context, selector) {
const value = useContext(context);
const selectedRef = useRef();
const selected = selector(value);
// Shallow equality check
if (!shallowEqual(selectedRef.current, selected)) {
selectedRef.current = selected;
}
return selectedRef.current;
}
// Usage
function UserName() {
// Only re-renders when user.name changes, not other user properties
const name = useContextSelector(UserContext, ctx => ctx.user.name);
return <div>{name}</div>;
}
Combined Strategies - The Hybrid Approach
Best Practice: Layer Multiple Optimizations
- Split by domain (user, cart, UI state)
- Memoize each context value (prevent parent re-renders)
- Separate state/dispatch for action-heavy contexts (cart, forms)
- Add selectors for large state objects (product catalog, data tables)
Example: E-commerce App Architecture
// Static contexts (theme, feature flags) - top of tree
<ThemeContext.Provider value={theme}>
// Semi-static contexts (user, auth) - memoized
<UserContext.Provider value={memoizedUser}>
// Dynamic contexts with state/dispatch separation
<CartStateContext.Provider value={cart}>
<CartDispatchContext.Provider value={dispatch}>
// High-frequency contexts with selectors
<ProductContext.Provider value={products}>
<App />
</ProductContext.Provider>
</CartDispatchContext.Provider>
</CartStateContext.Provider>
</UserContext.Provider>
</ThemeContext.Provider>
Performance vs Complexity Trade-off:
- Simple app (<20 components): useMemo only
- Medium app (20-100 components): Split contexts + useMemo
- Large app (100-500 components): Split + useMemo + state/dispatch separation
- Complex app (500+ components): All strategies + selectors + consider Redux/Zustand
Migration Strategy:
Start simple, add complexity only when metrics prove it's needed:
- Build with single context + useMemo
- Profile with React DevTools (identify hot spots)
- If >20 components re-render on unrelated updates β Split contexts
- If action components re-render unnecessarily β Separate state/dispatch
- If large state with partial consumption β Add selectors
The key insight: Most apps need split contexts + useMemo. State/dispatch and selectors are optimizations for specific patterns, not universal requirements. Always measure before optimizing - premature context optimization adds complexity without proven benefit.
π¬ Explain to Juniorβ
Understanding Context Performance Like a Newsletter Subscription
Imagine you subscribe to a daily newsletter that covers sports, technology, and cooking. Every day, you receive an email with all three sections, even though you only care about technology. You have to open the email (re-render), scan through it, and ignore the sports and cooking sections every single day. That's inefficient!
The Problem:
React Context works similarly. When you create a context with multiple pieces of data (user, theme, cart, notifications), every component that subscribes to that context gets "notified" whenever ANY piece of data changes - even if they only care about one specific piece.
// This is like subscribing to a combined newsletter
const AppContext = createContext();
// The newsletter contains everything
const value = {
user: currentUser,
theme: 'dark',
cart: ['item1', 'item2'],
notifications: ['new message']
};
// Component only cares about theme
function ThemeToggle() {
const { theme } = useContext(AppContext);
// But it re-renders when user, cart, OR notifications change!
return <button>Switch to {theme === 'dark' ? 'light' : 'dark'}</button>;
}
When the cart updates, the ThemeToggle button re-renders even though it doesn't use cart data. It's like receiving an entire newspaper when only the sports scores changed.
Solution 1: Separate Newsletters (Split Contexts)
Instead of one combined newsletter, subscribe to separate ones for each topic:
// Three separate newsletters!
const UserContext = createContext();
const ThemeContext = createContext();
const CartContext = createContext();
// Now your theme toggle only subscribes to theme newsletter
function ThemeToggle() {
const { theme } = useContext(ThemeContext);
// Only re-renders when theme changes! β
return <button>Switch to {theme === 'dark' ? 'light' : 'dark'}</button>;
}
Analogy: You only get cooking newsletter updates in your inbox, not every time there's sports or tech news.
Solution 2: Stable Subscription (useMemo)
Sometimes the newsletter company sends you duplicate emails because their system regenerates the email list every hour, even if content hasn't changed. Using useMemo is like telling them: "Only send me an email if the actual content changed, not just because you regenerated the list."
function NewsletterProvider({ children }) {
const [articles, setArticles] = useState([]);
// β Creates new object every time parent updates
const value = { articles, setArticles };
// β
Only creates new object when articles actually change
const value = useMemo(
() => ({ articles, setArticles }),
[articles] // Only if this changes
);
return <Context.Provider value={value}>{children}</Context.Provider>;
}
Solution 3: Read-Only vs Write-Only Subscriptions
Imagine you have two newsletter options:
- Read subscription: You get emails with news content (re-renders when news changes)
- Write subscription: You can submit articles, but don't receive emails (never re-renders)
This is the state/dispatch separation pattern:
const NewsStateContext = createContext(); // Read subscription
const NewsDispatchContext = createContext(); // Write subscription
function SubmitArticleButton() {
// Only subscribe to "write" - never gets "read" updates!
const dispatch = useContext(NewsDispatchContext);
// This button NEVER re-renders when news articles update β
return (
<button onClick={() => dispatch({ type: 'ADD_ARTICLE', text: 'News' })}>
Submit Article
</button>
);
}
function NewsList() {
// Subscribes to "read" - gets updates when articles change
const articles = useContext(NewsStateContext);
return articles.map(article => <div>{article}</div>);
}
Solution 4: Smart Filtering (Selectors)
This is like telling the newsletter: "Only email me when technology articles contain the word 'React', ignore everything else."
// Custom hook that filters what triggers re-renders
function useNewsSelector(selector) {
const allNews = useContext(NewsContext);
// Only re-renders if selected piece changes
return selector(allNews);
}
function ReactNews() {
// Only re-renders when React articles change, not ALL tech news
const reactArticles = useNewsSelector(news =>
news.tech.filter(article => article.tags.includes('React'))
);
return reactArticles.map(article => <div>{article.title}</div>);
}
Interview Answer Template
Question: "How do you optimize Context performance?"
Answer Structure:
1. Identify the Problem (30 seconds) "The main performance issue with React Context is that all consumers re-render whenever the context value changes, even if they only use a small part of that value. This happens because React uses reference equality to detect changes, and creating a new object on every render triggers updates to all subscribers."
2. Explain Primary Solutions (90 seconds)
"I use four main optimization strategies depending on the situation:
First, split contexts by domain. Instead of one AppContext with user, theme, and cart, I create separate UserContext, ThemeContext, and CartContext. Now changing the cart only re-renders components that use cart data, not the entire app.
Second, memoize context values with useMemo. This prevents creating new object references when the data hasn't actually changed, which eliminates unnecessary re-renders from parent component updates.
Third, separate state and dispatch contexts. For action-heavy components like buttons and forms, I create two contexts - one for state, one for dispatch. Components that only dispatch actions never re-render when state changes.
Fourth, use selector patterns for large state objects. This allows components to subscribe to specific slices of state and only re-render when that slice changes."
3. Provide Concrete Example (60 seconds)
"For example, in my last project, we had a shopping cart context that was causing the entire product list to re-render on every cart update. I split CartStateContext and CartDispatchContext, so the 'Add to Cart' buttons only subscribed to dispatch. This eliminated 80 unnecessary component re-renders per cart update, reducing interaction time from 450ms to 95ms."
4. Show Decision-Making (30 seconds)
"I profile with React DevTools first to identify actual bottlenecks before optimizing. For simple cases, just useMemo is enough. For apps with distinct domains like user auth and shopping cart, I split contexts. For complex apps with large state objects, I add selectors. The key is measuring first, then optimizing based on data."
Total Time: ~3.5 minutes
Key Concepts to Remember:
- Context re-renders ALL consumers when value reference changes
- New object = new reference = ALL consumers re-render
- Split contexts = Best general solution for unrelated state
- useMemo = Prevents parent re-renders from propagating
- State/dispatch separation = Perfect for action-heavy components
- Selectors = Advanced technique for fine-grained control
- Always profile first = Optimize based on measured impact, not assumptions
Common Mistakes to Avoid:
- β Putting all global state in one context
- β Forgetting to memoize context values
- β Optimizing before profiling (premature optimization)
- β Using selectors everywhere (adds complexity)
- β Not splitting high-frequency and low-frequency state
The fundamental insight: React can't track which properties you use from a context value, so it treats the whole value as atomic. All optimizations work around this limitation by either reducing what changes (splitting, memoizing) or adding explicit tracking (selectors).
</details>
Question 3: When to Use Redux vs Zustand vs Context?β
Difficulty: π‘ Medium Frequency: ββββ Time: 8 minutes Companies: All companies with complex state
Questionβ
Compare React Context, Redux, and Zustand. When should you use each?
Answerβ
Key Differences:
- Context: Built-in, simple, good for infrequent updates
- Redux: Powerful, lots of boilerplate, time-travel debugging
- Zustand: Minimal boilerplate, hooks-based, good middle ground
Code Exampleβ
// 1. REACT CONTEXT (Simple state)
const TodoContext = createContext();
function TodoProvider({ children }) {
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text, done: false }]);
};
return (
<TodoContext.Provider value={{ todos, addTodo }}>
{children}
</TodoContext.Provider>
);
}
// 2. ZUSTAND (Medium complexity)
import create from 'zustand';
const useTodoStore = create((set) => ({
todos: [],
addTodo: (text) =>
set((state) => ({
todos: [...state.todos, { id: Date.now(), text, done: false }]
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map(t =>
t.id === id ? { ...t, done: !t.done } : t
)
}))
}));
// Usage
function TodoList() {
const todos = useTodoStore(state => state.todos); // Selector!
const addTodo = useTodoStore(state => state.addTodo);
return (
<div>
{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
<button onClick={() => addTodo('New task')}>Add</button>
</div>
);
}
// 3. REDUX TOOLKIT (Complex state)
import { createSlice, configureStore } from '@reduxjs/toolkit';
const todoSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
addTodo: (state, action) => {
state.push({ id: Date.now(), text: action.payload, done: false });
},
toggleTodo: (state, action) => {
const todo = state.find(t => t.id === action.payload);
if (todo) todo.done = !todo.done;
}
}
});
const store = configureStore({ reducer: { todos: todoSlice.reducer } });
// Usage
import { useSelector, useDispatch } from 'react-redux';
function TodoList() {
const todos = useSelector(state => state.todos);
const dispatch = useDispatch();
return (
<div>
{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
<button onClick={() => dispatch(addTodo('New task'))}>Add</button>
</div>
);
}
// DECISION MATRIX
// Context: Theme, auth, locale (rarely changes, widely needed)
// Zustand: Medium apps, simple global state, good DX
// Redux: Large apps, complex state, time-travel, middleware needs
Resourcesβ
π Deep Diveβ
State Management Libraries: Architectural Differences and Implementation Details
Understanding when to use Context, Zustand, or Redux requires deep knowledge of how each tool works internally and what trade-offs they make. These aren't just API differences - they represent fundamentally different approaches to state management.
React Context: The Native Solution
React Context is built directly into React's fiber reconciliation system. When you create a context, React adds a context object to the fiber tree. Each Provider creates a new context value scope, and consumers subscribe to the nearest provider up the tree. This integration with React's core means:
Implementation Details:
// Internally, React maintains a context stack during rendering
// Pseudo-code of how Context works in React's fiber reconciliation:
function readContext(context) {
const value = currentlyRenderingFiber.dependencies.get(context);
if (value === undefined) {
// Walk up fiber tree to find nearest Provider
let fiber = currentlyRenderingFiber.return;
while (fiber !== null) {
if (fiber.type === Provider && fiber.context === context) {
value = fiber.pendingProps.value;
break;
}
fiber = fiber.return;
}
}
// Mark this fiber as dependent on this context
currentlyRenderingFiber.dependencies.add(context, value);
return value;
}
Context's performance characteristics stem from this implementation:
- Re-render propagation: O(n) where n = number of consumers (React walks consumer list)
- Context lookup: O(h) where h = tree height (walks up fiber tree)
- Memory: ~100 bytes per context + value size
- No selector support: Cannot subscribe to partial state (all-or-nothing)
Zustand: The Minimal External Store
Zustand implements a lightweight external state store using JavaScript closures and React's useSyncExternalStore (React 18) or a custom subscription system (React <18). The entire store is just a closure with subscribers:
// Simplified Zustand implementation
function create(createState) {
let state;
const listeners = new Set();
const setState = (partial) => {
const nextState = typeof partial === 'function'
? partial(state)
: partial;
if (nextState !== state) {
const previousState = state;
state = Object.assign({}, state, nextState);
listeners.forEach(listener => listener(state, previousState));
}
};
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const getState = () => state;
state = createState(setState, getState);
return function useStore(selector = getState, equalityFn = Object.is) {
const [, forceUpdate] = useReducer(c => c + 1, 0);
const stateRef = useRef(state);
const selectorRef = useRef(selector);
const selectedStateRef = useRef();
useEffect(() => {
return subscribe((state) => {
const selectedState = selectorRef.current(state);
if (!equalityFn(selectedStateRef.current, selectedState)) {
selectedStateRef.current = selectedState;
forceUpdate();
}
});
}, []);
return selector(state);
};
}
Zustand's Performance Characteristics:
- Re-render propagation: O(m * s) where m = number of subscribers, s = selector execution time
- Selector evaluation: Runs on every state change, but prevents re-render if result unchanged
- Memory: ~500 bytes for store + subscription overhead (~50 bytes per subscriber)
- Built-in selector support: Only re-renders when selected slice changes
- No React tree dependency: Store exists outside React component tree
Redux (with Redux Toolkit): The Flux Architecture
Redux implements the Flux pattern with a single immutable state tree, reducers for updates, and a sophisticated middleware system. Modern Redux (Redux Toolkit) uses Immer for immutable updates:
// Redux internals (simplified)
function createStore(reducer, preloadedState, enhancer) {
let currentState = preloadedState;
let currentReducer = reducer;
let currentListeners = [];
let nextListeners = currentListeners;
let isDispatching = false;
function getState() {
return currentState;
}
function subscribe(listener) {
nextListeners.push(listener);
return function unsubscribe() {
const index = nextListeners.indexOf(listener);
nextListeners.splice(index, 1);
};
}
function dispatch(action) {
if (isDispatching) {
throw new Error('Reducers may not dispatch actions');
}
try {
isDispatching = true;
currentState = currentReducer(currentState, action);
} finally {
isDispatching = false;
}
const listeners = (currentListeners = nextListeners);
listeners.forEach(listener => listener());
return action;
}
return { dispatch, subscribe, getState };
}
Redux's Performance Characteristics:
- Re-render propagation: O(m * s) similar to Zustand, but with middleware overhead
- Middleware execution: O(k) where k = number of middleware (runs on every dispatch)
- Memory: ~2-5KB for store + DevTools extension integration
- Immutability enforcement: Immer adds ~50-100ms for large state trees (structural sharing)
- Time-travel debugging: Stores action history (configurable, can use significant memory)
React 18 useSyncExternalStore Integration:
Both Zustand and Redux now use useSyncExternalStore for React 18+ applications, which provides:
- Concurrent rendering compatibility
- Automatic tearing prevention (multiple components always see consistent state)
- Improved performance during concurrent updates
// How Zustand/Redux integrate with useSyncExternalStore
import { useSyncExternalStore } from 'react';
function useStore(selector) {
return useSyncExternalStore(
store.subscribe, // Subscribe function
() => selector(store.getState()), // Get current state
() => selector(store.getServerState()) // Server state (SSR)
);
}
Architectural Trade-offs:
1. State Locality vs Globality:
- Context: Scoped to component subtree (can have multiple instances)
- Zustand: Global singleton (one instance per store)
- Redux: Global singleton with potential for store composition
2. Update Mechanism:
- Context: Direct state setter (useState/useReducer)
- Zustand: Imperative setState with automatic batching
- Redux: Action dispatch β reducer β new state (strict unidirectional flow)
3. DevTools Integration:
- Context: React DevTools only (see component state)
- Zustand: Redux DevTools extension (with middleware)
- Redux: Full Redux DevTools support (action history, time travel, state diffing)
4. Middleware Support:
- Context: None (must build custom)
- Zustand: Middleware API (persist, devtools, immer)
- Redux: Rich middleware ecosystem (thunk, saga, observable)
5. TypeScript Inference:
- Context: Excellent (type inference from createContext)
- Zustand: Good (type inference from store definition)
- Redux: Excellent with RTK (strong typing for actions, state, thunks)
6. Bundle Size Impact:
- Context: 0 bytes (built-in)
- Zustand: ~1.2KB gzipped
- Redux Toolkit: ~12KB gzipped (includes Immer, Thunk, DevTools integration)
Advanced Pattern: Combining Multiple Solutions:
High-performance applications often use multiple state management solutions:
// Static/semi-static data β Context
<ThemeContext.Provider value={theme}>
<LocaleContext.Provider value={locale}>
{/* Client state β Zustand */}
<ZustandProvider store={uiStore}>
{/* Server state β React Query/SWR */}
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</ZustandProvider>
</LocaleContext.Provider>
</ThemeContext.Provider>
This hybrid approach leverages each tool's strengths:
- Context: Theme, i18n, feature flags (rarely change)
- Zustand: UI state, client-side cache, transient state
- React Query: Server state, API cache, mutations
- Redux: Complex business logic requiring middleware/time-travel debugging
Performance Benchmarks (1000 Components, 100 State Updates):
| Solution | Initial Render | Re-render (all) | Re-render (10%) | Memory |
|---|---|---|---|---|
| Context (optimized) | 45ms | 380ms | 220ms | 2KB |
| Zustand | 42ms | 120ms | 18ms | 3KB |
| Redux | 48ms | 135ms | 22ms | 15KB |
| Context (unoptimized) | 45ms | 1200ms | 1200ms | 2KB |
Benchmark: React 18, production build, Chrome DevTools
The key insight: Context is fast when optimized but requires manual optimization. Zustand and Redux are fast by default due to built-in selector support. Redux's additional overhead comes from DevTools, middleware, and action tracking - which provide value for complex applications.
π Real-World Scenarioβ
Production Migration: Context β Zustand β Redux at SaaS Dashboard Platform
Background: A B2B SaaS dashboard platform with 50,000 monthly active users underwent three state management migrations over 18 months. The platform displayed real-time analytics, user management, billing information, and complex data visualizations. Each migration was driven by specific performance and scalability challenges.
Phase 1: React Context Implementation (Month 1-6)
Initial Architecture:
// Single AppContext with all global state
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [workspace, setWorkspace] = useState(null);
const [analytics, setAnalytics] = useState({});
const [filters, setFilters] = useState({});
const [notifications, setNotifications] = useState([]);
const value = useMemo(() => ({
user, setUser,
workspace, setWorkspace,
analytics, setAnalytics,
filters, setFilters,
notifications, setNotifications
}), [user, workspace, analytics, filters, notifications]);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
Initial Metrics (Month 3):
- Components using AppContext: 45 components
- Average re-renders per analytics update: 45 components
- Dashboard load time: 2.8 seconds
- Filter interaction response: 180-250ms
- Memory usage: 15MB baseline
- Lighthouse Performance Score: 68/100
Problems Encountered:
Issue 1: Analytics Widget Re-render Storm Real-time analytics updated every 5 seconds, causing ALL 45 components to re-render:
// Analytics widget updates every 5 seconds
useEffect(() => {
const interval = setInterval(() => {
fetchAnalytics().then(data => setAnalytics(data));
// This re-renders Header, Sidebar, UserMenu, ALL chart widgets, etc.
}, 5000);
return () => clearInterval(interval);
}, []);
Metrics During Analytics Updates:
- Re-renders per update: 45 components (100% of context consumers)
- JavaScript execution time: 420ms per update cycle
- Frame drops: 65% of frames during update
- User complaints: "Charts stutter and freeze during updates"
Issue 2: Filter Changes Blocked UI Changing dashboard filters caused full app re-render:
function FilterPanel() {
const { filters, setFilters } = useContext(AppContext);
const handleFilterChange = (key, value) => {
setFilters({ ...filters, [key]: value });
// Every component re-renders, including unrelated user menu, notifications, etc.
};
}
Debugging Step:
// Added Profiler to measure impact
<Profiler id="AppContext" onRender={(id, phase, actualDuration) => {
console.log(`${id} ${phase}: ${actualDuration}ms`);
if (actualDuration > 100) {
console.warn('Slow render detected!', {
duration: actualDuration,
timestamp: Date.now()
});
}
}}>
<AppProvider>
<Dashboard />
</AppProvider>
</Profiler>
Solution Attempt: Split Contexts
// Created 5 separate contexts
const UserContext = createContext();
const WorkspaceContext = createContext();
const AnalyticsContext = createContext();
const FiltersContext = createContext();
const NotificationsContext = createContext();
// Result: Improved, but still problems
Metrics After Context Splitting (Month 5):
- Re-renders per analytics update: 12 components (73% reduction) β
- Filter interaction response: 85ms (52% improvement) β
- BUT: Provider nesting became unwieldy (5 providers deep)
- BUT: Sharing logic between contexts required complex workarounds
- BUT: Team velocity decreased (lots of boilerplate per new context)
Decision Point (Month 6): Team decided Context wasn't scaling well. Need for:
- Better developer experience (less boilerplate)
- Built-in selector support (granular subscriptions)
- DevTools for debugging state changes
- Migration to Zustand approved
Phase 2: Zustand Migration (Month 7-12)
New Architecture:
// Separate Zustand stores by domain
import create from 'zustand';
import { devtools } from 'zustand/middleware';
// Analytics store with selectors
const useAnalyticsStore = create(
devtools((set) => ({
data: {},
isLoading: false,
setData: (data) => set({ data }),
setLoading: (isLoading) => set({ isLoading })
}))
);
// Filters store
const useFiltersStore = create(
devtools((set) => ({
dateRange: 'last7days',
category: 'all',
setDateRange: (dateRange) => set({ dateRange }),
setCategory: (category) => set({ category }),
reset: () => set({ dateRange: 'last7days', category: 'all' })
}))
);
// User store
const useUserStore = create(
devtools((set) => ({
user: null,
workspace: null,
setUser: (user) => set({ user }),
setWorkspace: (workspace) => set({ workspace })
}))
);
Migration Strategy:
// Gradual migration - replace Context one component at a time
// Week 1-2: Migrate analytics components
function RevenueChart() {
// OLD: const { analytics } = useContext(AnalyticsContext);
// NEW: Selector only subscribes to revenue data
const revenue = useAnalyticsStore(state => state.data.revenue);
// Only re-renders when revenue changes, not other analytics! β
return <LineChart data={revenue} />;
}
// Week 3-4: Migrate filter components
function FilterPanel() {
// OLD: const { filters, setFilters } = useContext(FiltersContext);
// NEW: Granular subscriptions
const dateRange = useFiltersStore(state => state.dateRange);
const setDateRange = useFiltersStore(state => state.setDateRange);
// Other components using filters.category don't re-render! β
return <DateRangePicker value={dateRange} onChange={setDateRange} />;
}
// Week 5-8: Migrate remaining components
Metrics After Zustand Migration (Month 10):
- Re-renders per analytics update: 3-5 components (88% total reduction from original) β
- JavaScript execution time: 45ms per analytics update (89% improvement) β
- Filter interaction response: 22ms (91% improvement) β
- Dashboard load time: 1.4 seconds (50% improvement) β
- Frame drops: <5% (92% improvement) β
- Developer velocity: 40% faster feature development (less boilerplate) β
- Lighthouse Performance Score: 89/100 (31% improvement) β
- Bundle size increase: +1.2KB gzipped (negligible)
Developer Experience Improvements:
// Before (Context): 15 lines of boilerplate per new state
const NewContext = createContext();
export const useNew = () => useContext(NewContext);
export function NewProvider({ children }) {
const [state, setState] = useState(initial);
const value = useMemo(() => ({ state, setState }), [state]);
return <NewContext.Provider value={value}>{children}</NewContext.Provider>;
}
// After (Zustand): 5 lines
const useNewStore = create((set) => ({
state: initial,
setState: (state) => set({ state })
}));
DevTools Benefits:
// Zustand DevTools integration
const useFiltersStore = create(
devtools(
(set) => ({ /* ... */ }),
{ name: 'FiltersStore' } // Shows in Redux DevTools!
)
);
// Team could now:
// - Track all filter changes in DevTools
// - See before/after state diffs
// - Export/import state for bug reproduction
// - Monitor state changes in production (with sampling)
Zustand Worked Well For:
- β UI state (filters, modals, selections)
- β Client-side cache (user preferences, temp data)
- β Simple global state (theme, sidebar state)
- β Fast prototyping (minimal boilerplate)
But Zustand Struggled With:
- β Complex async workflows (had to write custom logic)
- β Business rule validation (no middleware for validation)
- β Audit logging (actions not first-class citizens)
- β Time-travel debugging (limited compared to Redux)
Phase 3: Partial Redux Migration (Month 13-18)
Problem Discovery (Month 12): The platform added a complex billing workflow with:
- Multi-step checkout process
- Payment validation with retry logic
- Rollback on payment failure
- Audit logging for compliance
- Undo/redo for invoice editing
Zustand Implementation Became Complex:
// Billing store became unwieldy with manual middleware
const useBillingStore = create(
devtools(
auditLog( // Custom middleware
persist( // Persist middleware
immer((set) => ({ // Immer middleware
invoice: null,
history: [],
historyIndex: -1,
updateInvoice: (updates) => set((state) => {
// Manual immutability
// Manual history tracking
// Manual validation
// Manual audit logging
// This grew to 200+ lines of complex logic
}),
undo: () => set((state) => {
// Manual undo implementation (50+ lines)
}),
redo: () => set((state) => {
// Manual redo implementation (50+ lines)
})
}))
)
)
)
);
Decision (Month 13): Migrate billing workflow to Redux Toolkit for:
- Built-in async thunk support
- Immer integration (automatic immutability)
- Redux DevTools time-travel
- Middleware for validation/audit logging
Hybrid Architecture (Final):
// Zustand for simple UI state
const useUIStore = create((set) => ({
sidebarOpen: true,
modalVisible: false,
toggleSidebar: () => set(state => ({ sidebarOpen: !state.sidebarOpen }))
}));
// Redux Toolkit for complex business logic
import { createSlice, configureStore } from '@reduxjs/toolkit';
const billingSlice = createSlice({
name: 'billing',
initialState: { invoice: null, status: 'idle' },
reducers: {
updateInvoice: (state, action) => {
// Immer automatically handles immutability
state.invoice = { ...state.invoice, ...action.payload };
}
},
extraReducers: (builder) => {
builder
.addCase(processPayment.pending, (state) => {
state.status = 'processing';
})
.addCase(processPayment.fulfilled, (state, action) => {
state.status = 'success';
state.invoice = action.payload;
})
.addCase(processPayment.rejected, (state) => {
state.status = 'failed';
// Automatic rollback via time-travel debugging
});
}
});
// Middleware for audit logging
const auditMiddleware = store => next => action => {
const result = next(action);
if (action.type.startsWith('billing/')) {
logAudit({
action: action.type,
timestamp: Date.now(),
user: store.getState().user,
state: store.getState().billing
});
}
return result;
};
const store = configureStore({
reducer: { billing: billingSlice.reducer },
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(auditMiddleware)
});
Final Metrics (Month 18):
- Dashboard performance: Maintained (Zustand for UI)
- Billing workflow time: 180ms per action (fast with Redux)
- Audit compliance: 100% (middleware automatically logs)
- Developer productivity: High (Zustand for simple, Redux for complex)
- Bundle size: +13KB gzipped total (12KB Redux, 1KB Zustand)
- Lighthouse Performance Score: 91/100 β
Business Impact:
- Customer satisfaction: +28% (faster, more reliable dashboard)
- Developer onboarding time: -35% (clear patterns for simple vs complex state)
- Billing errors: -94% (Redux time-travel helped debug payment issues)
- Audit compliance: Passed SOC 2 audit (Redux middleware logging)
Lessons Learned:
- Start with Context for simple apps - Built-in, zero bundle size
- Migrate to Zustand when Context becomes painful - Better DX, selectors, DevTools
- Add Redux for complex workflows - Middleware, time-travel, audit trails
- Use hybrid approach - Zustand for UI, Redux for business logic, Context for static data
- Profile before optimizing - Metrics drove each migration decision
- Bundle size matters less than DX - +13KB was negligible compared to productivity gains
</details>
<details> <summary><strong>βοΈ Trade-offs: Redux vs Zustand vs Context Comparison</strong></summary>
Comprehensive State Management Decision Matrix
React Context
When to Choose: β You should use Context when:
- Application has <50 components
- State changes infrequently (theme, auth, locale)
- State is scoped to component subtree (not truly global)
- Zero bundle size is priority
- Team is uncomfortable with external dependencies
- Simple CRUD with minimal state complexity
- SSR/SSG with minimal client-side interactivity
β Avoid Context when:
- Frequent state updates (>5 per second)
- Large number of components consuming state (>50)
- Need granular re-render control
- Complex async workflows
- Need DevTools for debugging
- Multiple developers working on state logic
Performance Profile:
Initial render: β
β
β
β
β
(no overhead)
Re-render speed: β
β
βββ (without optimization)
β
β
β
β
β (with optimization)
Memory usage: β
β
β
β
β
(~100 bytes per context)
Bundle size: β
β
β
β
β
(0 bytes - built-in)
DevTools: β
β
βββ (React DevTools only)
Type safety: β
β
β
β
β
(excellent TypeScript inference)
Learning curve: β
β
β
β
β
(built into React)
Code Complexity:
// Simple state: EASY β
const [theme, setTheme] = useState('dark');
// Complex state: HARD β
// - Need multiple contexts to avoid re-render issues
// - Manual optimization with useMemo/useCallback
// - No built-in middleware
// - No built-in persistence
// - No built-in DevTools integration
Real-World Context Limits:
- Max components before optimization needed: ~20-30
- Max state update frequency: ~2-3 per second
- Recommended context count: 3-5 (more = provider hell)
Zustand
When to Choose: β You should use Zustand when:
- Context is becoming painful (too many re-renders)
- Need granular re-render control (selectors)
- Want minimal boilerplate vs Redux
- Team values simplicity and speed
- UI state, client cache, transient state
- Prototyping or startup moving fast
- Modern React (hooks-first approach)
- Need DevTools but not full Redux power
β Avoid Zustand when:
- Need strict action tracking for audit logs
- Complex middleware requirements (Zustand middleware is limited)
- Team already knows Redux well
- Time-travel debugging is critical
- Large-scale app with 10+ developers (Redux patterns scale better)
Performance Profile:
Initial render: β
β
β
β
β
(minimal overhead)
Re-render speed: β
β
β
β
β
(selector-based, very fast)
Memory usage: β
β
β
β
β (~500 bytes + subscribers)
Bundle size: β
β
β
β
β
(1.2KB gzipped)
DevTools: β
β
β
β
β (Redux DevTools via middleware)
Type safety: β
β
β
β
β (good TypeScript inference)
Learning curve: β
β
β
β
β
(15 minutes to learn)
Code Complexity:
// Simple state: VERY EASY β
const useStore = create((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 }))
}));
// Complex state: MEDIUM β
// - Middleware available but limited
// - Can become messy with complex async logic
// - Immer integration helps with nested updates
const useStore = create(
devtools(
immer((set) => ({
nested: { data: [] },
addItem: (item) => set(state => {
state.nested.data.push(item); // Immer makes this safe
})
}))
)
);
Real-World Zustand Sweet Spot:
- Application size: Small to medium (up to 500 components)
- Team size: 1-10 developers
- State complexity: Low to medium (simple workflows)
- Update frequency: High (handles 60fps animations well)
- Best for: Modern SaaS apps, dashboards, tools
Redux (Redux Toolkit)
When to Choose: β You should use Redux when:
- Large application (500+ components)
- Complex business logic with strict rules
- Need middleware (sagas, observables, custom)
- Audit logging required (compliance, SOC 2)
- Time-travel debugging needed (complex bug reproduction)
- Multiple developers (Redux patterns enforce consistency)
- Team coming from Flux/Redux background
- Server state management without React Query/SWR
β Avoid Redux when:
- Simple application (<100 components)
- Team is new to React (steep learning curve)
- Rapid prototyping (too much boilerplate)
- UI state only (Zustand is faster to write)
- Server state (use React Query/SWR instead)
Performance Profile:
Initial render: β
β
β
β
β (small overhead from middleware)
Re-render speed: β
β
β
β
β
(selector-based, very fast)
Memory usage: β
β
β
ββ (2-5KB + action history)
Bundle size: β
β
β
ββ (12KB gzipped with RTK)
DevTools: β
β
β
β
β
(best-in-class time-travel)
Type safety: β
β
β
β
β
(excellent with RTK)
Learning curve: β
β
βββ (days to weeks)
Code Complexity:
// Simple state: MEDIUM COMPLEXITY β οΈ
// More boilerplate than Context/Zustand, but organized
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => { state.value += 1; }
}
});
// Complex state: EASIER THAN ZUSTAND β
// Async thunks, middleware, Immer integration built-in
const userSlice = createSlice({
name: 'user',
initialState: { data: null, status: 'idle' },
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'success';
state.data = action.payload;
})
.addCase(fetchUser.rejected, (state) => {
state.status = 'failed';
});
}
});
// Middleware for cross-cutting concerns
const loggerMiddleware = store => next => action => {
console.log('Action:', action);
console.log('State before:', store.getState());
const result = next(action);
console.log('State after:', store.getState());
return result;
};
Real-World Redux Sweet Spot:
- Application size: Medium to large (500+ components)
- Team size: 10+ developers
- State complexity: High (complex workflows, business rules)
- Audit requirements: High (financial, healthcare, compliance)
- Best for: Enterprise apps, FinTech, E-commerce platforms
Decision Tree
Start
|
ββ Is state static/rarely changes? (theme, locale)
| ββ YES β Use Context β
|
ββ Is app small (<100 components)?
| ββ YES β Use Context + useState β
|
ββ Need minimal boilerplate + DevTools?
| ββ YES β Use Zustand β
|
ββ Complex async workflows?
| ββ YES β Consider Redux or React Query
|
ββ Need time-travel debugging?
| ββ YES β Use Redux β
|
ββ Team >10 developers?
| ββ YES β Use Redux (enforces patterns) β
|
ββ Audit logging required?
| ββ YES β Use Redux (middleware) β
|
ββ Default β Start with Zustand, migrate to Redux if needed
Bundle Size Comparison (gzipped)
| Solution | Size | What's Included |
|---|---|---|
| Context | 0 KB | Built into React |
| Zustand | 1.2 KB | Store + subscriptions |
| Zustand + DevTools | 1.8 KB | + Redux DevTools middleware |
| Zustand + Immer + Persist | 5 KB | + Immer + LocalStorage middleware |
| Redux Toolkit | 12 KB | Redux + Immer + Thunk + DevTools |
| Redux Toolkit + Saga | 23 KB | + redux-saga for complex async |
Migration Path
Recommended progression:
- Start: Context + useState
- Scale: Zustand (when >50 components or frequent updates)
- Complexity: Redux (when workflows need middleware/audit)
Migration is incremental:
// You can run all three simultaneously!
<ThemeContext.Provider value={theme}> {/* Static state */}
<Provider store={reduxStore}> {/* Complex business logic */}
<QueryClientProvider client={client}> {/* Server state */}
<App /> {/* + Zustand for UI state */}
</QueryClientProvider>
</Provider>
</ThemeContext.Provider>
Common Hybrid Pattern:
- Context: Theme, i18n, feature flags (static)
- Zustand: UI state, modals, filters (client state)
- React Query: API data (server state)
- Redux: Billing, permissions, audit logs (complex business logic)
The key insight: Modern apps don't need to choose ONE solution. Use the right tool for each type of state. Context for static, Zustand for simple global, Redux for complex workflows, React Query for server state.
π¬ Explain to Juniorβ
Choosing State Management Like Choosing a Vehicle
Imagine you need to move things around. You have three options:
- Walking (React Context)
- Bicycle (Zustand)
- Truck (Redux)
Each is perfect for different situations!
React Context = Walking
When it's perfect:
- Going to your mailbox (short distance)
- Carrying a small package
- Already have shoes (built into React!)
- Don't want to maintain a vehicle
When it's NOT good:
- Moving furniture across town (too slow, too hard)
- Making deliveries all day (inefficient)
- Carrying lots of items (exhausting)
// Walking is simple - just go!
const ThemeContext = createContext();
function App() {
const [theme, setTheme] = useState('dark');
// Pass theme to everyone (like shouting to neighbors)
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Header /> {/* Hears the theme */}
<Main /> {/* Hears the theme */}
</ThemeContext.Provider>
);
}
function Header() {
const { theme } = useContext(ThemeContext);
return <div className={theme}>Header</div>;
}
The problem with "walking" (Context): When you shout (update context), EVERYONE hears it, even if they don't care. If you shout "The theme changed!" then Header, Main, Footer, Sidebar all respond - even components that don't use theme!
Zustand = Bicycle
When it's perfect:
- Commuting to work (medium distance)
- Grocery shopping (multiple stops)
- Want to go faster than walking
- Low maintenance (no gas, insurance)
When it's NOT good:
- Moving a refrigerator (need more power)
- Delivering to 100 addresses (need logistics)
- Team of 20 people coordinating (need structure)
// Get a bicycle - simple setup!
import create from 'zustand';
const useStore = create((set) => ({
theme: 'dark',
user: null,
// Actions are like pedaling - simple and direct
setTheme: (theme) => set({ theme }),
setUser: (user) => set({ user })
}));
function Header() {
// Only subscribe to theme - ignore user changes!
const theme = useStore(state => state.theme);
// This component ONLY re-renders when theme changes β
// Doesn't care about user, cart, or anything else
return <div className={theme}>Header</div>;
}
function UserProfile() {
// Only subscribe to user - ignore theme changes!
const user = useStore(state => state.user);
// This component ONLY re-renders when user changes β
return <div>{user.name}</div>;
}
Why Zustand is a "bicycle":
- Faster than walking (better performance than Context)
- Selective listening (components only hear what they care about)
- Low maintenance (minimal code to write)
- Easy to learn (15 minutes and you're riding!)
Redux = Truck
When it's perfect:
- Moving house (big, complex operation)
- Running a delivery company (many drivers, many routes)
- Need GPS tracking (DevTools time-travel)
- Need loading dock procedures (middleware)
When it's NOT good:
- Getting milk from the corner store (overkill!)
- Solo developer building a prototype (too much setup)
- Simple apps (maintenance overhead not worth it)
// Get a truck - more setup, but powerful!
import { createSlice, configureStore } from '@reduxjs/toolkit';
// Define loading procedures (reducers)
const themeSlice = createSlice({
name: 'theme',
initialState: { mode: 'dark' },
reducers: {
setTheme: (state, action) => {
state.mode = action.payload;
},
toggleTheme: (state) => {
state.mode = state.mode === 'dark' ? 'light' : 'dark';
}
}
});
// Create the truck (store)
const store = configureStore({
reducer: {
theme: themeSlice.reducer
}
});
// Dispatch actions (give delivery instructions)
function Header() {
const theme = useSelector(state => state.theme.mode);
const dispatch = useDispatch();
return (
<div className={theme}>
<button onClick={() => dispatch(setTheme('light'))}>
Switch Theme
</button>
</div>
);
}
Why Redux is a "truck":
- Powerful (can handle complex workflows)
- Structured (loading procedures = reducers, GPS = DevTools)
- Team-friendly (multiple drivers follow same procedures)
- Trackable (time-travel debugging = replay entire delivery route)
- More overhead (need loading dock, procedures, training)
Truck features (Redux advantages):
- Middleware = Loading dock inspections (validate every action, log for audit)
- Time-travel = GPS recording (replay entire journey to find bugs)
- DevTools = Control center (see all deliveries, state, history)
Real-World Example: Building a Shopping App
Small Shop (Context):
// Just you running a corner store
const CartContext = createContext();
function Shop() {
const [cart, setCart] = useState([]);
return (
<CartContext.Provider value={{ cart, setCart }}>
<Products />
<Checkout />
</CartContext.Provider>
);
}
Medium Shop (Zustand):
// Small team, multiple stores
const useShopStore = create((set) => ({
cart: [],
user: null,
inventory: [],
addToCart: (item) => set(state => ({
cart: [...state.cart, item]
})),
checkout: () => set({ cart: [] })
}));
// Each component only listens to what it needs
function Cart() {
const cart = useShopStore(state => state.cart);
// Doesn't re-render when inventory changes β
}
function Inventory() {
const inventory = useShopStore(state => state.inventory);
// Doesn't re-render when cart changes β
}
Large Shop (Redux):
// Enterprise with warehouses, compliance, audit trails
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], checkoutStatus: 'idle' },
reducers: {
addToCart: (state, action) => {
state.items.push(action.payload);
}
},
extraReducers: (builder) => {
builder
.addCase(checkout.pending, (state) => {
state.checkoutStatus = 'processing';
})
.addCase(checkout.fulfilled, (state) => {
state.checkoutStatus = 'success';
state.items = [];
});
}
});
// Middleware for audit logging (compliance requirement)
const auditMiddleware = store => next => action => {
console.log('Action:', action); // Log for audit
return next(action);
};
Interview Answer Template
Question: "When should you use Context vs Zustand vs Redux?"
Answer Structure:
1. Context (30 seconds) "I use React Context for simple, infrequent state that's scoped to part of the app - things like theme, authentication, or locale. Context is built into React, so there's no bundle size cost. The key limitation is that all consumers re-render when any value changes, so I only use it for state that changes rarely or when I've optimized with split contexts."
2. Zustand (45 seconds) "Zustand is my go-to for global client state in modern apps. It's tiny (1.2KB), has built-in selector support so components only re-render when their data changes, and integrates with Redux DevTools. I use it for UI state like filters, modals, and client-side caching. The DX is excellent - minimal boilerplate compared to Redux. It's perfect for small to medium apps where you need performance but don't need Redux's full power."
3. Redux (45 seconds) "I reach for Redux when I need structure and power - complex async workflows, middleware for audit logging or validation, or time-travel debugging. Redux Toolkit makes it much better than old Redux - it includes Immer for immutable updates and has excellent TypeScript support. I use it for business logic that needs strict validation, like payment processing or permission systems. The bundle size is larger (12KB), but that's worth it for complex applications."
4. Decision Framework (30 seconds) "My rule of thumb: start with Context for static state like theme. If Context becomes painful with frequent updates, migrate to Zustand. If workflows get complex and need middleware, add Redux for just those parts. Modern apps often use all three - Context for theme, Zustand for UI state, and Redux for complex business logic."
Total Time: ~2.5 minutes
Quick Decision Chart:
| Need | Use |
|---|---|
| Theme, locale, auth (static) | Context |
| UI state, filters, modals | Zustand |
| Complex workflows, audit logs | Redux |
| API data, server state | React Query/SWR |
| App <100 components | Context |
| App 100-500 components | Zustand |
| App 500+ components | Redux |
| Rapid prototyping | Zustand |
| Enterprise compliance | Redux |
Key Concepts:
- Context = Built-in, simple, all consumers re-render
- Zustand = Tiny, selectors, great DX, good for most apps
- Redux = Powerful, middleware, DevTools, better for complex apps
- Modern apps use multiple solutions (Context + Zustand + React Query)
- Start simple, add complexity when needed (don't over-engineer)
</details>
Source: state-02-redux-zustand.md