Module 04: State Management & Data Flow β Managing State at Scale
Estimated Time: 18-22 hours Difficulty: π΄ Advanced Prerequisites: Module 02 (Hooks), Module 03 (Patterns) Interview Weight: βββββ (Redux questions are extremely common)
π Table of Contents
- The State Management Landscape
- Types of State in React Apps
- Local State Patterns
- Context API at Scale
- Redux Deep Dive
- Redux Toolkit (Modern Redux)
- Zustand β Lightweight Alternative
- Server State: React Query / TanStack Query
- When to Use What β Decision Framework
- Common Mistakes & Gotchas
- Practice Problems
- Interview Questions
- "What's the Output?" Challenges
1. The State Management Landscape
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β The State Management Spectrum β
β β
β Simple ββββββββββββββββββββββββββββββββββββΊ Complex β
β β
β useState Context Zustand Redux Redux + β
β useReducer API Jotai Toolkit Saga/Thunk β
β Recoil β
β β
β ββββββββ ββββββββββ βββββββββ ββββββββββββββββββββ β
β βLocal β βShared β βGlobal β βEnterprise-scale β β
β βState β βSubtree β βStore β βwith middleware β β
β ββββββββ ββββββββββ βββββββββ ββββββββββββββββββββ β
β β
β Use the SIMPLEST tool that solves your problem. β
β Don't use Redux for a todo app! β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ2. Types of State in React Apps
Understanding WHAT kind of state you're dealing with determines HOW to manage it:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β State Type β Examples β Best Tool β
ββββββββββββββββββββΌββββββββββββββββββββββΌβββββββββββββββββββ€
β UI State β Modal open/closed β useState β
β (local) β Tab selection β useReducer β
β β Form input values β β
ββββββββββββββββββββΌββββββββββββββββββββββΌβββββββββββββββββββ€
β Shared UI State β Theme (dark/light) β Context API β
β (cross-comp) β User auth status β Zustand β
β β Sidebar collapsed β β
ββββββββββββββββββββΌββββββββββββββββββββββΌβββββββββββββββββββ€
β Server State β User profile data β React Query β
β (cached) β Product listings β SWR β
β β Search results β RTK Query β
ββββββββββββββββββββΌββββββββββββββββββββββΌβββββββββββββββββββ€
β Complex Client β Shopping cart β Redux Toolkit β
β State β Multi-step wizard β Zustand β
β β Collaborative β β
β β editing β β
ββββββββββββββββββββΌββββββββββββββββββββββΌβββββββββββββββββββ€
β URL State β Current page β React Router β
β β Search params β URL search paramsβ
β β Filters β β
ββββββββββββββββββββΌββββββββββββββββββββββΌβββββββββββββββββββ€
β Form State β Validation β React Hook Form β
β β Touched/dirty β Formik β
β β Submission status β β
ββββββββββββββββββββ΄ββββββββββββββββββββββ΄βββββββββββββββββββπ‘ Key Insight: The biggest mistake people make is putting ALL state in one place (usually Redux). Different types of state belong in different places. Server state should NOT be in Redux.
3. Local State Patterns
Lifting State Up (Revisited)
// When siblings need to share state, lift it to their common parent
function TemperatureCalculator() {
const [temp, setTemp] = useState('');
const [scale, setScale] = useState('celsius');
const celsius = scale === 'fahrenheit' ? ((parseFloat(temp) - 32) * 5/9) : parseFloat(temp);
const fahrenheit = scale === 'celsius' ? ((parseFloat(temp) * 9/5) + 32) : parseFloat(temp);
return (
<div>
<TemperatureInput
label="Celsius"
value={scale === 'celsius' ? temp : celsius.toFixed(2)}
onChange={(val) => { setTemp(val); setScale('celsius'); }}
/>
<TemperatureInput
label="Fahrenheit"
value={scale === 'fahrenheit' ? temp : fahrenheit.toFixed(2)}
onChange={(val) => { setTemp(val); setScale('fahrenheit'); }}
/>
</div>
);
}
function TemperatureInput({ label, value, onChange }) {
return (
<label>
{label}: <input value={value} onChange={e => onChange(e.target.value)} />
</label>
);
}State Colocation β Keep State Close to Where It's Used
// β Over-lifting: State is too far from where it's used
function App() {
const [searchTerm, setSearchTerm] = useState(''); // Only used in SearchPage!
const [sortOrder, setSortOrder] = useState('asc'); // Only used in ProductList!
return (
<Layout>
<SearchPage searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
<ProductList sortOrder={sortOrder} setSortOrder={setSortOrder} />
</Layout>
);
}
// β
Colocated: State lives where it's used
function App() {
return (
<Layout>
<SearchPage /> {/* Manages its own search state */}
<ProductList /> {/* Manages its own sort state */}
</Layout>
);
}4. Context API at Scale
When Context Works Well
β
Good use cases for Context:
βββ Theme (light/dark)
βββ Current user / authentication
βββ Locale / internationalization
βββ Feature flags
βββ Any data that rarely changes and is needed by many componentsWhen Context Falls Short
β Poor use cases for Context:
βββ Frequently changing data (causes re-renders of ALL consumers)
βββ Complex state logic with many actions
βββ State that needs middleware (logging, persistence)
βββ State that needs time-travel debugging
βββ High-performance scenarios with many consumersOptimizing Context Performance
import { createContext, useContext, useState, useMemo, memo } from 'react';
// β Naive approach β everything re-renders on any change
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
// New object every render = ALL consumers re-render
return (
<AppContext.Provider value={{ user, setUser, theme, setTheme }}>
{children}
</AppContext.Provider>
);
}
// β
Optimized β separate contexts + memoized values
const UserContext = createContext();
const ThemeContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState(null);
// Memoize the value object
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
// Now: changing theme doesn't re-render user consumers!
function App() {
return (
<UserProvider>
<ThemeProvider>
<MainApp />
</ThemeProvider>
</UserProvider>
);
}5. Redux Deep Dive
What is Redux?
Redux is a predictable state container for JavaScript apps. It stores all application state in a single object (the "store") and uses a strict pattern to update it.
Real-World Analogy
Think of Redux as a bank:
- Store = The bank vault (holds all the money/state)
- Action = A withdrawal/deposit slip (describes what you want to do)
- Reducer = The bank teller (processes the slip and updates the vault)
- Dispatch = Handing the slip to the teller (triggering the update)
- Selector = Your bank statement (reading specific data from the vault)
The Three Principles
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Redux Three Principles: β
β β
β 1. Single Source of Truth β
β The entire app state lives in ONE store. β
β Makes debugging and serialization easy. β
β β
β 2. State is Read-Only β
β You can't modify state directly. β
β The only way to change state is to DISPATCH β
β an ACTION β a plain object describing what β
β happened. β
β β
β 3. Changes via Pure Functions β
β Reducers are pure functions: β
β (previousState, action) β newState β
β No side effects, no mutations, always β
β predictable. β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββRedux Data Flow
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β Redux Data Flow β
β β
β ββββββββββββ β
β β UI β 1. User clicks "Add to Cart" β
β β (React) ββββββββββββββββββββββ β
β ββββββββββββ β β
β β² βΌ β
β β ββββββββββββββββ β
β β β Action β β
β 6. UI re-renders β Creator β β
β with new data β addToCart() β β
β β ββββββββ¬ββββββββ β
β β β β
β β 2. Returns action: β
β β { type: 'ADD_TO_CART', β
β ββββββ΄βββββββ payload: { id: 1 } } β
β β Store β β β
β β .getStateβ 3. dispatch(action) β
β ββββββ¬βββββββ β β
β β βΌ β
β 5. Store updates ββββββββββββββββ β
β and notifies β Reducer β β
β subscribers β β β
β β² β (state, action) β
β β β β newState β β
β β ββββββββ¬ββββββββ β
β β β β
β ββββββββββββββββββββββββββββ β
β 4. Returns new state β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββVanilla Redux Example (Without Toolkit)
import { createStore } from 'redux';
// Action Types
const INCREMENT = 'counter/increment';
const DECREMENT = 'counter/decrement';
const RESET = 'counter/reset';
// Action Creators
const increment = () => ({ type: INCREMENT });
const decrement = () => ({ type: DECREMENT });
const reset = () => ({ type: RESET });
// Reducer
const initialState = { count: 0 };
function counterReducer(state = initialState, action) {
switch (action.type) {
case INCREMENT:
return { ...state, count: state.count + 1 };
case DECREMENT:
return { ...state, count: state.count - 1 };
case RESET:
return { ...state, count: 0 };
default:
return state;
}
}
// Create Store
const store = createStore(counterReducer);
// Subscribe to changes
store.subscribe(() => {
console.log('State changed:', store.getState());
});
// Dispatch actions
store.dispatch(increment()); // { count: 1 }
store.dispatch(increment()); // { count: 2 }
store.dispatch(decrement()); // { count: 1 }
store.dispatch(reset()); // { count: 0 }Connecting Redux to React (Legacy β connect)
import { connect } from 'react-redux';
// The old way β using connect HOC
function Counter({ count, increment, decrement }) {
return (
<div>
<p>{count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
// mapStateToProps: what state does this component need?
const mapStateToProps = (state) => ({
count: state.counter.count,
});
// mapDispatchToProps: what actions does this component dispatch?
const mapDispatchToProps = {
increment,
decrement,
};
export default connect(mapStateToProps, mapDispatchToProps)(Counter);Modern Redux with Hooks
import { useSelector, useDispatch } from 'react-redux';
function Counter() {
// useSelector replaces mapStateToProps
const count = useSelector(state => state.counter.count);
// useDispatch replaces mapDispatchToProps
const dispatch = useDispatch();
return (
<div>
<p>{count}</p>
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(decrement())}>-</button>
</div>
);
}Redux Middleware
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Middleware sits between dispatch and reducer: β
β β
β dispatch(action) β
β β β
β βΌ β
β ββββββββββββ ββββββββββββ ββββββββββββ β
β β Logger ββββΆβ Thunk ββββΆβ Reducer β β
β β Middlewareβ βMiddlewareβ β β β
β ββββββββββββ ββββββββββββ ββββββββββββ β
β β
β Middleware can: β
β - Log every action (redux-logger) β
β - Handle async actions (redux-thunk, redux-saga) β
β - Persist state (redux-persist) β
β - Report errors (sentry-redux) β
β - Cancel, delay, or modify actions β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ6. Redux Toolkit (Modern Redux)
Why Redux Toolkit?
Redux Toolkit (RTK) is the official, recommended way to write Redux. It solves the main complaints about vanilla Redux:
Vanilla Redux problems β Redux Toolkit solutions:
βββ Too much boilerplate β createSlice auto-generates actions
βββ Need many packages β All included in @reduxjs/toolkit
βββ Immutable updates hard β Uses Immer (write "mutable" code safely)
βββ Store setup complex β configureStore does it all
βββ No async pattern β createAsyncThunk built-in
βββ No selectors β Reselect includedSetting Up Redux Toolkit
npm install @reduxjs/toolkit react-reduxCreating a Slice
// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter', // Prefix for action types
initialState: {
count: 0,
status: 'idle',
},
reducers: {
// Each reducer automatically generates an action creator!
increment(state) {
state.count += 1; // β
"Mutation" is OK β Immer handles immutability!
},
decrement(state) {
state.count -= 1;
},
incrementByAmount(state, action) {
state.count += action.payload;
},
reset(state) {
state.count = 0;
},
},
});
// Auto-generated action creators
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;
// Selectors
export const selectCount = (state) => state.counter.count;
// The reducer
export default counterSlice.reducer;π Under the Hood: Immer
// How Immer makes "mutations" safe:
// What you write:
increment(state) {
state.count += 1; // Looks like mutation!
}
// What Immer does behind the scenes:
// 1. Creates a "draft" (proxy) of the state
// 2. Tracks all changes you make to the draft
// 3. Produces a new immutable state based on the changes
// Equivalent to:
increment(state) {
return { ...state, count: state.count + 1 }; // Immer does this for you
}
// β οΈ Important rules with Immer:
// Either mutate the draft OR return a new object β never both!
// β
Mutate
increment(state) {
state.count += 1;
}
// β
Return new object
increment(state) {
return { ...state, count: state.count + 1 };
}
// β NEVER do both
increment(state) {
state.count += 1;
return state; // Ambiguous! Don't mix patterns
}Configuring the Store
// store/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import todosReducer from '../features/todos/todosSlice';
import userReducer from '../features/user/userSlice';
const store = configureStore({
reducer: {
counter: counterReducer,
todos: todosReducer,
user: userReducer,
},
// DevTools are automatically enabled in development!
// Thunk middleware is automatically included!
});
export default store;Providing the Store to React
// main.jsx
import { Provider } from 'react-redux';
import store from './store/store';
ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>
);Async Actions with createAsyncThunk
// features/users/usersSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// Async thunk β handles pending/fulfilled/rejected automatically
export const fetchUsers = createAsyncThunk(
'users/fetchUsers', // Action type prefix
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch');
return await response.json(); // This becomes action.payload
} catch (error) {
return rejectWithValue(error.message);
}
}
);
export const deleteUser = createAsyncThunk(
'users/deleteUser',
async (userId, { rejectWithValue }) => {
try {
await fetch(`/api/users/${userId}`, { method: 'DELETE' });
return userId; // Return the ID so reducer can remove it
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const usersSlice = createSlice({
name: 'users',
initialState: {
items: [],
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
},
reducers: {
// Regular synchronous reducers here
},
extraReducers: (builder) => {
builder
// Fetch Users
.addCase(fetchUsers.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload;
})
// Delete User
.addCase(deleteUser.fulfilled, (state, action) => {
state.items = state.items.filter(u => u.id !== action.payload);
});
},
});
export const selectUsers = (state) => state.users.items;
export const selectUsersStatus = (state) => state.users.status;
export const selectUsersError = (state) => state.users.error;
export default usersSlice.reducer;Using in Components
import { useSelector, useDispatch } from 'react-redux';
import { fetchUsers, deleteUser, selectUsers, selectUsersStatus, selectUsersError } from './usersSlice';
import { useEffect } from 'react';
function UserList() {
const dispatch = useDispatch();
const users = useSelector(selectUsers);
const status = useSelector(selectUsersStatus);
const error = useSelector(selectUsersError);
useEffect(() => {
if (status === 'idle') {
dispatch(fetchUsers());
}
}, [status, dispatch]);
if (status === 'loading') return <p>Loading users...</p>;
if (status === 'failed') return <p>Error: {error}</p>;
return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name}
<button onClick={() => dispatch(deleteUser(user.id))}>
Delete
</button>
</li>
))}
</ul>
);
}7. Zustand β Lightweight Alternative
Why Zustand?
Zustand is a minimal state management library. No boilerplate, no providers, no reducers:
Redux Toolkit:
βββ createSlice with reducers
βββ configureStore
βββ <Provider store={store}>
βββ useSelector + useDispatch
βββ createAsyncThunk for async
βββ ~15 files for a feature
Zustand:
βββ create() with a function
βββ Use the hook directly
βββ ~1 file for a featureZustand Example
import { create } from 'zustand';
// Create a store β it's just a function!
const useStore = create((set, get) => ({
// State
count: 0,
users: [],
isLoading: false,
// Actions (just functions that call set)
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
// Async actions β no middleware needed!
fetchUsers: async () => {
set({ isLoading: true });
try {
const response = await fetch('/api/users');
const users = await response.json();
set({ users, isLoading: false });
} catch (error) {
set({ isLoading: false });
console.error('Failed to fetch users:', error);
}
},
// Access other state values
incrementIfBelowMax: (max) => {
const { count } = get();
if (count < max) {
set({ count: count + 1 });
}
},
}));
// Usage in components β no Provider needed!
function Counter() {
const count = useStore(state => state.count);
const increment = useStore(state => state.increment);
return <button onClick={increment}>{count}</button>;
}
function UserList() {
const { users, isLoading, fetchUsers } = useStore();
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
if (isLoading) return <p>Loading...</p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}Zustand with Middleware
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
const useStore = create(
devtools( // Redux DevTools integration
persist( // Persist to localStorage
(set) => ({
theme: 'light',
toggleTheme: () => set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
}),
{ name: 'theme-store' } // localStorage key
)
)
);π Redux Toolkit vs Zustand
| Feature | Redux Toolkit | Zustand |
|---|---|---|
| Bundle size | ~11KB | ~1.5KB |
| Boilerplate | Moderate | Minimal |
| Provider needed? | Yes | No |
| DevTools | Built-in | Middleware |
| Middleware | Built-in (thunk) | Plugin-based |
| Learning curve | Steeper | Very gentle |
| TypeScript | Good | Excellent |
| Ecosystem | Huge | Growing |
| Time-travel debug | Yes | Via DevTools middleware |
| Best for | Large teams, complex apps | Small-medium apps |
8. Server State: React Query / TanStack Query
The Problem with useEffect for Data Fetching
// β The naive approach has MANY problems:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => { if (!cancelled) setUser(data); })
.catch(err => { if (!cancelled) setError(err); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [userId]);
// Missing features:
// β No caching β re-fetches on every mount
// β No background refetching β data gets stale
// β No deduplication β multiple components fetch same data
// β No retry on failure
// β No pagination support
// β No optimistic updates
// β No garbage collection
// β No request cancellation on fast navigation
}React Query Solves All of This
npm install @tanstack/react-query// Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // Data is "fresh" for 5 minutes
gcTime: 10 * 60 * 1000, // Cache garbage collected after 10 min
retry: 3, // Retry failed requests 3 times
refetchOnWindowFocus: true, // Refetch when user returns to tab
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<MainApp />
</QueryClientProvider>
);
}Basic Query
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const {
data: user,
isLoading,
isError,
error,
isFetching, // True when fetching (including background refetch)
isStale, // True when data is stale
refetch, // Manually trigger refetch
} = useQuery({
queryKey: ['user', userId], // Unique key for caching
queryFn: async () => { // The fetch function
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
},
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 min
enabled: !!userId, // Only fetch if userId exists
});
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
{isFetching && <small>Updating...</small>}
</div>
);
}Mutations (Create, Update, Delete)
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUserForm() {
const queryClient = useQueryClient();
const createUser = useMutation({
mutationFn: async (newUser) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
});
if (!res.ok) throw new Error('Failed to create user');
return res.json();
},
onSuccess: (newUser) => {
// Invalidate the users query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ['users'] });
// Or optimistically update the cache
// queryClient.setQueryData(['users'], (old) => [...old, newUser]);
},
onError: (error) => {
alert(`Error: ${error.message}`);
},
});
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
createUser.mutate({
name: formData.get('name'),
email: formData.get('email'),
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? 'Creating...' : 'Create User'}
</button>
{createUser.isError && <p style={{ color: 'red' }}>{createUser.error.message}</p>}
</form>
);
}π How React Query Caching Works
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β React Query Cache: β
β β
β Query Key: ['users'] β
β ββββββββββββββββββββββββββββ β
β β Status: success β β
β β Data: [{...}, {...}] β β
β β Last Updated: 5min ago β β
β β Stale: true (>5min) β β
β β Observers: 2 components β β
β ββββββββββββββββββββββββββββ β
β β
β Query Key: ['user', 42] β
β ββββββββββββββββββββββββββββ β
β β Status: success β β
β β Data: {id:42, name:...} β β
β β Last Updated: 1min ago β β
β β Stale: false (<5min) β β
β β Observers: 1 component β β
β ββββββββββββββββββββββββββββ β
β β
β When Component A and Component B both call β
β useQuery(['user', 42]): β
β - Only ONE network request is made β
β - Both components share the same cached data β
β - When data becomes stale, a background refetch β
β updates both components β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ9. When to Use What β Decision Framework
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β "What state management should I use?" β
β β
β Is it SERVER data (from API)? β
β βββ YES β React Query / TanStack Query β
β β (with caching, refetching, etc.) β
β βββ NO β
β β β
β Is it used by only ONE component? β
β βββ YES β useState / useReducer β
β βββ NO β
β β β
β Is it used by a few nearby components? β
β βββ YES β Lift state up β
β βββ NO β
β β β
β Does it change rarely? β
β (theme, auth, locale) β
β βββ YES β Context API β
β βββ NO β
β β β
β Is the app small/medium? β
β βββ YES β Zustand β
β βββ NO β
β β β
β Large team/enterprise? β
β βββ YES β Redux Toolkit β
β βββ NO β Zustand β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββπ Comparison Summary
| Feature | useState | Context | Zustand | Redux TK | React Query |
|---|---|---|---|---|---|
| Scope | Local | Subtree | Global | Global | Server data |
| Boilerplate | None | Low | Low | Medium | Low |
| DevTools | React DevTools | React DevTools | Redux DevTools | Redux DevTools | RQ DevTools |
| Async | Manual | Manual | Built-in | createAsyncThunk | Built-in |
| Caching | No | No | No | No | Yes β |
| Learning | Easy | Easy | Easy | Medium | Easy-Medium |
| Best for | Component state | App-wide config | UI state | Complex client | API data |
10. Common Mistakes & Gotchas
Mistake 1: Putting Server State in Redux
// β Don't store API data in Redux
const usersSlice = createSlice({
name: 'users',
initialState: { data: [], loading: false, error: null },
// ... lots of boilerplate for fetching, caching, invalidation
// You're reinventing React Query!
});
// β
Use React Query for server state
const { data: users } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
// Automatic caching, refetching, deduplication, retry...Mistake 2: Creating New Object Reference in Context Value
// β New object every render = all consumers re-render
function Provider({ children }) {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
// β
Memoize the value
function Provider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}Mistake 3: Over-subscribing to Redux Store
// β Selecting the entire state β re-renders on ANY state change
const state = useSelector(state => state);
// β Creating new object β re-renders every time
const data = useSelector(state => ({
count: state.counter.count,
user: state.user.name,
}));
// β
Select only what you need β individual selectors
const count = useSelector(state => state.counter.count);
const userName = useSelector(state => state.user.name);11. Practice Problems
π§ͺ Problem 1: Build a Shopping Cart with Redux Toolkit
Build a shopping cart with add, remove, update quantity, and clear actions.
<details> <summary>β Solution</summary>// cartSlice.js
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], total: 0 },
reducers: {
addItem(state, action) {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
},
removeItem(state, action) {
state.items = state.items.filter(i => i.id !== action.payload);
state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
},
updateQuantity(state, action) {
const { id, quantity } = action.payload;
const item = state.items.find(i => i.id === id);
if (item) {
item.quantity = Math.max(0, quantity);
if (item.quantity === 0) {
state.items = state.items.filter(i => i.id !== id);
}
}
state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
},
clearCart(state) {
state.items = [];
state.total = 0;
},
},
});
export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions;
export const selectCartItems = (state) => state.cart.items;
export const selectCartTotal = (state) => state.cart.total;
export const selectCartCount = (state) =>
state.cart.items.reduce((sum, i) => sum + i.quantity, 0);
export default cartSlice.reducer;π§ͺ Problem 2: Build a Zustand Store for a Todo App
<details> <summary>β Solution</summary>import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useTodoStore = create(
persist(
(set, get) => ({
todos: [],
filter: 'all', // 'all' | 'active' | 'completed'
addTodo: (text) => set((state) => ({
todos: [...state.todos, {
id: Date.now(),
text,
completed: false,
createdAt: new Date().toISOString(),
}],
})),
toggleTodo: (id) => set((state) => ({
todos: state.todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
),
})),
deleteTodo: (id) => set((state) => ({
todos: state.todos.filter(t => t.id !== id),
})),
clearCompleted: () => set((state) => ({
todos: state.todos.filter(t => !t.completed),
})),
setFilter: (filter) => set({ filter }),
get filteredTodos() {
const { todos, filter } = get();
switch (filter) {
case 'active': return todos.filter(t => !t.completed);
case 'completed': return todos.filter(t => t.completed);
default: return todos;
}
},
get activeCount() {
return get().todos.filter(t => !t.completed).length;
},
}),
{ name: 'todo-storage' }
)
);
export default useTodoStore;12. Interview Questions
β Q1: Explain the Redux data flow.
Answer:
- User interaction triggers an event (click, form submit)
- Component dispatches an action β a plain object with
typeand optionalpayload - The store passes current state and action to the reducer
- The reducer (pure function) returns a new state based on the action type
- The store updates and notifies all subscribed components
- Components re-render with the new state
Key principles: single store, state is read-only (immutable updates), reducers are pure functions.
β Q2: What is Redux middleware? Give examples.
Answer: Middleware provides a third-party extension point between dispatching an action and the moment it reaches the reducer. It's used for:
- redux-thunk: Handle async actions (dispatch functions instead of objects)
- redux-saga: Complex async flows using generator functions
- redux-logger: Log actions and state changes
- redux-persist: Save state to localStorage/AsyncStorage
Middleware intercepts actions and can: log them, transform them, delay them, cancel them, or dispatch additional actions.
β Q3: When would you use Context API vs Redux?
Answer:
Context API: For data that changes rarely and is needed by many components β theme, auth, locale. It's simple but triggers re-renders of ALL consumers when ANY value changes.
Redux: For complex client state with many update patterns, middleware needs, or when you want time-travel debugging. Better performance with useSelector (only re-renders when selected data changes).
Neither: For server data, use React Query. For local component state, use useState/useReducer.
β Q4: What is React Query? Why not just fetch in useEffect?
Answer: React Query (TanStack Query) is a server state management library. Raw useEffect fetching lacks:
- Caching β same data fetched multiple times
- Deduplication β multiple components trigger same request
- Background refetching β data goes stale
- Retry logic β no automatic retry on failure
- Garbage collection β unused cache not cleaned up
- Optimistic updates β UI doesn't respond instantly
- Pagination/infinite scroll β complex to implement
React Query provides all of these out of the box with minimal code.
β Q5: What are Redux selectors? Why use them?
Answer: Selectors are functions that extract specific data from the Redux store:
// Simple selector
const selectUserName = (state) => state.user.name;
// Derived selector (with Reselect for memoization)
const selectCompletedTodos = createSelector(
selectTodos,
(todos) => todos.filter(t => t.completed)
);Benefits: encapsulate state shape (components don't know store structure), memoize expensive computations, prevent unnecessary re-renders.
13. "What's the Output?" Challenges
Challenge 1
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
function Counter() {
const count = useStore((state) => state.count);
console.log('Counter rendered');
return <span>{count}</span>;
}
function IncrementButton() {
const increment = useStore((state) => state.increment);
console.log('Button rendered');
return <button onClick={increment}>+</button>;
}
// After clicking the button once, what logs?Counter renderedOnly Counter re-renders! IncrementButton subscribes only to state.increment (a function reference that never changes), so it doesn't re-render when count changes. This is Zustand's key advantage β fine-grained subscriptions.
π― Module 04 Summary
You now understand:
- β Different types of state and where each belongs
- β Context API β usage, limitations, and performance optimization
- β Redux β core concepts, data flow, middleware
- β Redux Toolkit β slices, createAsyncThunk, Immer
- β Zustand β lightweight alternative to Redux
- β React Query β server state management
- β Decision framework β when to use what
Next up: Module 05: Performance & React Internals β the "secret weapon" module that separates junior from senior developers. β