1.2 Functional State Updates and Updater Functions
Pattern
Syntax
When to Use
Advantage
Direct Update
setState(newValue)
New value doesn't depend on previous state
Simple, straightforward
Functional Update RECOMMENDED
setState(prev => newValue)
New value depends on previous state
Prevents stale closure issues, guarantees latest state
Updater with Logic
setState(prev => prev + delta)
Calculations based on current state
Safe for async updates and batching
Scenario
❌ Problematic
✅ Correct
Multiple Updates
setCount(count + 1); setCount(count + 1);
setCount(c => c + 1); setCount(c => c + 1);
Event Handler
onClick={() => setCount(count + 1)}
onClick={() => setCount(c => c + 1)}
Async Callback
setTimeout(() => setCount(count + 1), 1000)
setTimeout(() => setCount(c => c + 1), 1000)
Example: Functional updates prevent stale closures
// ❌ Problem: Both clicks only increment once totalfunction BadCounter() { const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); // Uses stale 'count' setCount(count + 1); // Uses same stale 'count' }; return <button onClick={handleClick}>{count}</button>;}// ✅ Solution: Functional updates use latest statefunction GoodCounter() { const [count, setCount] = useState(0); const handleClick = () => { setCount(c => c + 1); // Uses latest state setCount(c => c + 1); // Uses result of previous update }; return <button onClick={handleClick}>{count}</button>; // Increments by 2}
Best Practice: Always use functional updates when the new state depends on the previous state,
especially in event handlers, async operations, and when multiple updates may be batched.
Example: Lazy initialization for expensive operations
// ❌ Bad: Reads localStorage on EVERY render (wasteful)function BadComponent() { const [data, setData] = useState( JSON.parse(localStorage.getItem('userData') || '{}') ); // This executes on every render, even though result is ignored after mount}// ✅ Good: Reads localStorage ONLY on initial mountfunction GoodComponent() { const [data, setData] = useState(() => { const saved = localStorage.getItem('userData'); return saved ? JSON.parse(saved) : {}; }); // Function executes once, subsequent renders skip it}// ✅ Good: Expensive calculation done only oncefunction DataProcessor({ rawData }) { const [processed, setProcessed] = useState(() => { console.log('Processing...'); // Logs only once return expensiveProcessing(rawData); });}
Warning: Lazy initializer function runs only once on mount. It won't re-run if props change.
For derived state from props, use useMemo or useEffect instead.
1.4 State Update Batching and React 18 Automatic Batching
React Version
Batching Scope
Behavior
React 17
Event handlers only
Batches updates in synthetic events, but not in setTimeout, promises,
native events
React 18+ NEW
All updates everywhere
Automatic batching in timeouts, promises, native events, and event handlers
Context
React 17 Batching
React 18 Batching
Re-renders
Event Handler
✅ Batched
✅ Batched
1 re-render for multiple setState calls
setTimeout
❌ Not batched
✅ Batched
React 18: 1 re-render; React 17: multiple
Promise.then
❌ Not batched
✅ Batched
React 18: 1 re-render; React 17: multiple
Native Event
❌ Not batched
✅ Batched
React 18: 1 re-render; React 17: multiple
API
Purpose
Usage
flushSync()
Force immediate synchronous update, opt-out of batching
flushSync(() => setState(value))
ReactDOM.unstable_batchedUpdates()
Manual batching in React 17 (deprecated in React 18)
import { useState } from 'react';import { flushSync } from 'react-dom';function Counter() { const [count, setCount] = useState(0); const [flag, setFlag] = useState(false); console.log('Render'); // Track re-renders // React 18: Both updates batched → 1 render // React 17: Both updates batched → 1 render function handleClick() { setCount(c => c + 1); setFlag(f => !f); // Single re-render after both updates } // React 18: Both updates batched → 1 render // React 17: Each update causes render → 2 renders function handleAsync() { setTimeout(() => { setCount(c => c + 1); setFlag(f => !f); // React 18: Single re-render }, 1000); } // React 18: Both updates batched → 1 render // React 17: Each update causes render → 2 renders function handlePromise() { fetch('/api').then(() => { setCount(c => c + 1); setFlag(f => !f); // React 18: Single re-render }); } // Opt-out: Force immediate synchronous updates function handleFlushSync() { flushSync(() => { setCount(c => c + 1); // Renders immediately }); flushSync(() => { setFlag(f => !f); // Renders immediately (separate) }); // Two separate re-renders }}
React 18 Benefit: Automatic batching improves performance by reducing re-renders. Multiple
state updates in any context are batched into a single re-render, leading to better performance and more
consistent behavior.
function UserForm() { const [user, setUser] = useState({ name: '', email: '', age: 0, address: { city: '', country: '' } }); // ❌ Wrong: Loses all other properties const updateNameBad = (name) => { setUser({ name }); // user now only has 'name', lost email, age, address }; // ✅ Correct: Spreads existing state, updates one field const updateName = (name) => { setUser(prev => ({ ...prev, name })); }; // ✅ Multiple fields at once const updateMultiple = () => { setUser(prev => ({ ...prev, name: 'John', age: 30 })); }; // ✅ Nested object update const updateCity = (city) => { setUser(prev => ({ ...prev, address: { ...prev.address, city } })); }; // ✅ Dynamic property name const updateField = (field, value) => { setUser(prev => ({ ...prev, [field]: value })); }; return ( <form> <input value={user.name} onChange={e => updateName(e.target.value)} /> <input value={user.email} onChange={e => updateField('email', e.target.value)} /> </form> );}
Warning:useState does NOT merge objects automatically like class component
setState. Always use the spread operator {...prev, ...changes} to preserve unmodified
properties.
Impossible States: State machines prevent scenarios like
{ loading: true, error: 'Failed', data: [...] } which are logically impossible but possible with
boolean flags.
2.5 useReducer vs useState Decision Matrix
Criterion
Use useState
Use useReducer
State Complexity
Simple primitives or shallow objects
Complex nested objects, multiple related values
Update Logic
Simple assignments: setState(value)
Complex logic, conditionals, multiple steps
Number of Updates
Few state update locations
Many different update operations
Predictability
Simple, local state changes
Need centralized, traceable state transitions
Testing
Simple component tests sufficient
Want to test reducer logic in isolation
Future Changes
State unlikely to grow in complexity
Anticipate adding more actions/transitions
Scenario
Best Choice
Reason
Toggle boolean flag
✅ useState
Simple on/off state, no complex logic
Form with validation
✅ useReducer
Multiple fields, validation rules, error states
Shopping cart
✅ useReducer
Add/remove/update/clear operations, quantities
Modal open/close
✅ useState
Binary state, simple toggle
Multi-step wizard
✅ useReducer
State machine with steps, navigation, data
Counter
✅ useState
Single value, simple increment/decrement
Async data fetching
✅ useReducer
Loading/success/error states, state machine
Example: When to migrate from useState to useReducer
Race Conditions: Multiple async operations can complete out of order. Use request IDs or abort
controllers to cancel stale requests. React 18's useTransition can also help manage async state
updates.
useReducer Best Practices Summary
Use switch statements for clear, explicit action handling
Create action creators for consistent action structure
Implement state machines for complex async flows
Choose useReducer when state has 3+ related pieces
Dispatch actions in async handlers for coordinated updates
Extract async logic into custom hooks for reusability
Use TypeScript discriminated unions for type-safe reducers
Default Value: The default value in createContext(defaultValue) is only used when
a component doesn't have a matching Provider above it in the tree. It's useful for testing components in
isolation.
import { createContext, useContext, useState } from 'react';const AuthContext = createContext(null);// Providerexport function AuthProvider({ children }) { const [user, setUser] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(false); const login = (credentials) => { // Login logic setUser({ name: credentials.username }); setIsAuthenticated(true); }; const logout = () => { setUser(null); setIsAuthenticated(false); }; const value = { user, isAuthenticated, login, logout }; return ( <AuthContext.Provider value={value}> {children} </AuthContext.Provider> );}// Custom hook with validationexport function useAuth() { const context = useContext(AuthContext); if (context === null) { throw new Error('useAuth must be used within AuthProvider'); } return context;}// Usage in componentfunction UserProfile() { const { user, logout } = useAuth(); // Safe, validated access return ( <div> <p>Welcome, {user.name}</p> <button onClick={logout}>Logout</button> </div> );}
Warning: Components using useContext will re-render when the context value
changes. This happens even if the component only uses a small part of the context value. See section 3.4 for
optimization techniques.
3.3 Multiple Context Providers and Context Composition
Pattern
Implementation
Use Case
Nested Providers
Stack multiple Providers in tree hierarchy
Different concerns (theme, auth, settings, etc.)
Composed Provider
Single component wrapping multiple Providers
Reduce nesting, cleaner App component
Dependent Contexts
One context consumes another context's value
Context depends on data from another context
Composition Strategy
Code Pattern
Pros/Cons
Manual Nesting
<A><B><C>{children}</C></B></A>
✅ Explicit, clear dependencies ❌ Verbose, deep nesting
Provider Composer
<ComposeProviders providers={[A, B, C]}>
✅ Clean, scalable ❌ Extra abstraction
Single Root Provider
<AppProvider> wraps all contexts
✅ Single import, organized ❌ All contexts loaded together
// UserPreferences context depends on AuthContextfunction UserPreferencesProvider({ children }) { const { user } = useAuth(); // Depends on AuthContext const [preferences, setPreferences] = useState(null); useEffect(() => { if (user) { // Load user preferences when user is available loadPreferences(user.id).then(setPreferences); } else { setPreferences(null); } }, [user]); const value = { preferences, setPreferences }; return ( <PreferencesContext.Provider value={value}> {children} </PreferencesContext.Provider> );}// Must be nested under AuthProviderfunction App() { return ( <AuthProvider> <UserPreferencesProvider> <Dashboard /> </UserPreferencesProvider> </AuthProvider> );}
3.4 Context Performance Optimization Techniques
Problem
Cause
Solution
Unnecessary Re-renders
Context value changes, all consumers re-render
Split contexts, memoize value, use selectors
New Object Every Render
Provider creates new value object on each render
Memoize context value with useMemo
Consuming Entire Context
Component re-renders even if it uses one field
Split context by concern, use selector pattern
Optimization Technique
Implementation
Benefit
Memoize Context Value ESSENTIAL
const value = useMemo(() => ({...}), [deps])
Prevent re-renders from object identity changes
Split Context by Concern
Separate contexts for data and updaters
Components only re-render when needed data changes
React.memo on Consumers
export default React.memo(Component)
Prevent re-renders from parent updates
Selector Pattern
Custom hook with selector function
Subscribe to specific slice of context state
Separate State/Dispatch Contexts
One context for state, another for dispatch
Components using only dispatch never re-render
Example: Memoizing context value
import { createContext, useState, useMemo } from 'react';// ❌ Bad: New object every render, causes unnecessary re-rendersfunction BadProvider({ children }) { const [count, setCount] = useState(0); const [name, setName] = useState(''); // New object reference on every render! const value = { count, setCount, name, setName }; return <MyContext.Provider value={value}>{children}</MyContext.Provider>;}// ✅ Good: Memoized value, only changes when dependencies changefunction GoodProvider({ children }) { const [count, setCount] = useState(0); const [name, setName] = useState(''); // Memoized - same object reference unless count or name changes const value = useMemo( () => ({ count, setCount, name, setName }), [count, name] ); return <MyContext.Provider value={value}>{children}</MyContext.Provider>;}
Example: Split context for state and dispatch
import { createContext, useContext, useReducer, useMemo } from 'react';// Separate contexts for state and dispatchconst TodoStateContext = createContext(null);const TodoDispatchContext = createContext(null);function TodoProvider({ children }) { const [state, dispatch] = useReducer(todoReducer, initialState); // State context value - changes when state changes const stateValue = useMemo(() => state, [state]); // Dispatch context value - never changes (dispatch is stable) const dispatchValue = useMemo(() => dispatch, []); return ( <TodoStateContext.Provider value={stateValue}> <TodoDispatchContext.Provider value={dispatchValue}> {children} </TodoDispatchContext.Provider> </TodoStateContext.Provider> );}// Hooks for consumingexport function useTodoState() { const context = useContext(TodoStateContext); if (!context) throw new Error('Must be used within TodoProvider'); return context;}export function useTodoDispatch() { const context = useContext(TodoDispatchContext); if (!context) throw new Error('Must be used within TodoProvider'); return context;}// Usage: Components only using dispatch never re-render when state changesfunction AddTodoButton() { const dispatch = useTodoDispatch(); // Won't re-render on state changes return ( <button onClick={() => dispatch({ type: 'ADD', payload: 'New Todo' })}> Add Todo </button> );}// Component using state re-renders when state changesfunction TodoList() { const { todos } = useTodoState(); // Re-renders when state changes return <ul>{todos.map(t => <li key={t.id}>{t.text}</li>)}</ul>;}
Example: Selector pattern for granular subscriptions
// Custom hook with selectorfunction useStoreSelector(selector) { const store = useContext(StoreContext); return selector(store);}// Usage: Subscribe to specific datafunction UserProfile() { // Only re-renders when user changes, not when cart or settings change const user = useStoreSelector(store => store.user); return <div>{user.name}</div>;}function CartCount() { // Only re-renders when cart items change const itemCount = useStoreSelector(store => store.cart.items.length); return <span>{itemCount} items</span>;}
Performance Tip: For high-frequency updates, consider external state management libraries like
Zustand or Jotai that have built-in selector optimizations, or use useSyncExternalStore for
external stores.
Updates state when prop changes (controlled pattern)
Key-based Reset
<Component key={props.id} />
Remounts component with fresh state when key changes
Example: Initialize state from props
// Pattern 1: Direct initialization (one-time)function Counter({ initialCount = 0 }) { // Only uses initialCount on first render const [count, setCount] = useState(initialCount); // If parent changes initialCount, this component's state won't update return <div>{count}</div>;}// Pattern 2: Sync with prop changes (controlled component)function SyncedCounter({ count: propCount }) { const [count, setCount] = useState(propCount); // Update local state when prop changes useEffect(() => { setCount(propCount); }, [propCount]); return <div>{count}</div>;}// Pattern 3: Key-based reset (best for resetting state)function EditForm({ userId }) { const [name, setName] = useState(''); const [email, setEmail] = useState(''); // When userId changes, component remounts with fresh state return <form>...</form>;}// Parent component uses key to reset EditFormfunction UserEditor({ userId }) { return <EditForm key={userId} userId={userId} />;}
Example: Initialize from URL and external sources
import { useState, useEffect } from 'react';import { useSearchParams } from 'react-router-dom';function ProductFilter() { // Initialize from URL query params const [searchParams] = useSearchParams(); const [category, setCategory] = useState(() => searchParams.get('category') || 'all' ); const [minPrice, setMinPrice] = useState(() => Number(searchParams.get('minPrice')) || 0 ); // Initialize from environment const [theme, setTheme] = useState( process.env.REACT_APP_DEFAULT_THEME || 'light' ); // Initialize from external API const [userPreferences, setUserPreferences] = useState(null); useEffect(() => { // Fetch user preferences on mount fetch('/api/preferences') .then(res => res.json()) .then(data => setUserPreferences(data)); }, []); return <div>Filter UI</div>;}
Common Mistake: Using useState(props.value) and expecting it to update when prop
changes. This creates a "stale initial state" issue. Use controlled component pattern with useEffect or
key-based reset instead.
4.2 Derived Initial State Patterns
Pattern
Implementation
Use Case
Computed from Props
useState(() => transformProps(props))
Process/transform props for initial state
Merged Defaults
useState({ ...defaults, ...props.overrides })
Combine default values with prop overrides
Normalized Data
useState(() => normalizeData(props.items))
Convert array to normalized structure (byId, allIds)
Performance Tip: Always use lazy initialization with arrow function when computing initial
state from props or external data. This ensures expensive computations run only once on mount, not on every
render.
4.3 State Hydration from localStorage/sessionStorage
Storage Limits: localStorage/sessionStorage typically have 5-10MB limits per origin. Always
handle quota exceeded errors. For larger data, use IndexedDB. Never store sensitive data unencrypted.
Recommendation: Prefer controlled components for React apps -
they integrate better with React's data flow and enable powerful features like real-time validation. Use uncontrolled only for performance-critical scenarios or when integrating with
non-React code.
4.5 Default Props and State Fallback Strategies
Fallback Strategy
Implementation
Use Case
Default Parameters
function Comp({ value = 'default' })
Simple primitive defaults
Nullish Coalescing
const val = prop ?? 'default'
Distinguish null/undefined from other falsy values
Modern Array Methods: ES2023 introduced immutable array methods: toSorted(),
toReversed(), toSpliced(), and with(). These create copies without
mutating the original. NEW
5.3 Nested Object State Updates with Immer Integration
Nesting Level
Manual Immutable Update
Complexity
1 Level
{ ...state, prop: value }
✅ Simple, manageable
2 Levels
{ ...state, nested: { ...state.nested, prop: value } }
When to Use Immer: If you have 3+ levels of nesting, multiple nested arrays, or complex update
logic, Immer significantly improves code readability and reduces bugs. Install:
npm install immer use-immer
5.4 State Normalization for Complex Data Structures
Normalization Libraries: For complex normalization, consider normalizr library or
Redux Toolkit's createEntityAdapter which provides utilities for normalized state management.
5.5 Avoiding State Mutation and Side Effects
Mutation Type
Example (❌ Wrong)
Why It Fails
Direct Assignment
state.value = 123; setState(state)
Same reference, React won't detect change
Array Methods
arr.push(item); setArr(arr)
Mutates original array, same reference
Object Methods
Object.assign(obj, updates); setObj(obj)
Mutates original object
Nested Assignment
state.nested.value = 1; setState({...state})
Shallow copy doesn't protect nested objects
Side Effect Type
Problem
Solution
Render-time Mutations
Changing state during render causes inconsistencies
// ❌ WRONG: Mutation during renderfunction BadComponent({ data }) { const [processed, setProcessed] = useState([]); // BAD: Setting state during render! if (data.length > 0 && processed.length === 0) { setProcessed(data.map(transform)); // Causes infinite loop or warnings } return <div>{processed.length}</div>;}// ✅ CORRECT: Side effects in useEffectfunction GoodComponent({ data }) { const [processed, setProcessed] = useState([]); useEffect(() => { setProcessed(data.map(transform)); }, [data]); return <div>{processed.length}</div>;}// ✅ BETTER: Compute during render without statefunction BestComponent({ data }) { const processed = useMemo(() => data.map(transform), [data]); return <div>{processed.length}</div>;}
React's Assumption: React assumes state updates are immutable. Mutating state directly breaks
React's diffing algorithm, can cause stale UI, missed re-renders, and hard-to-debug issues. Always create new
references.
5.6 State Update Optimization and Performance
Optimization
Technique
Benefit
Bail Out
Return same state object if no changes
React skips re-render if same reference
Structural Sharing
Reuse unchanged parts of state object
Reduces memory, faster comparisons
Batch Updates
Multiple setState calls batched into one render
Fewer re-renders (automatic in React 18)
Lazy Updates
Defer non-critical state updates
Prioritize important UI updates
Split State
Separate frequently/infrequently changing state
Localize re-renders to affected components
Anti-pattern
Problem
Solution
Unnecessary Copying
Always creating new object even when unchanged
Check if update is needed, return same state
Large State Objects
Updating small part causes full re-render
Split into smaller, focused state pieces
Derived State
Storing computed values in state
Compute during render or use useMemo
Deep Copying
JSON.parse(JSON.stringify()) for deep copy
Slow, use Immer or selective copying
Example: Bail out of updates
function OptimizedComponent() { const [count, setCount] = useState(0); const [user, setUser] = useState({ name: 'John', age: 30 }); // ❌ Always creates new object, even if unchanged const updateUserBad = (newName) => { setUser(prev => ({ ...prev, name: newName })); // Even if newName === prev.name, creates new object! }; // ✅ Bail out if unchanged const updateUserGood = (newName) => { setUser(prev => { if (prev.name === newName) { return prev; // Same reference, React skips re-render } return { ...prev, name: newName }; }); }; // ✅ Bail out for primitive values (automatic) const incrementIfEven = () => { setCount(prev => { if (prev % 2 === 0) { return prev + 1; } return prev; // Same value, React bails out }); }; return <div>{user.name}: {count}</div>;}
// ❌ Monolithic state - sidebar toggle re-renders entire appfunction BadApp() { const [state, setState] = useState({ todos: [], // Rarely changes user: {}, // Rarely changes sidebarOpen: true, // Changes frequently currentView: 'list' // Changes frequently }); // Every state change re-renders everything return <div>...</div>;}// ✅ Split state - localized re-rendersfunction GoodApp() { // Separate state by update frequency const [todos, setTodos] = useState([]); const [user, setUser] = useState({}); const [sidebarOpen, setSidebarOpen] = useState(true); const [currentView, setCurrentView] = useState('list'); // Or split into contexts for different concerns return ( <UserContext.Provider value={user}> <TodoContext.Provider value={todos}> <UIContext.Provider value={{ sidebarOpen, currentView }}> <Content /> </UIContext.Provider> </TodoContext.Provider> </UserContext.Provider> );}// Components using only UI state won't re-render when todos changefunction Sidebar() { const { sidebarOpen } = useContext(UIContext); return <aside>{sidebarOpen && 'Sidebar'}</aside>;}
Immutability Best Practices Summary
Always create new references for objects and arrays when updating
Use spread operators for shallow updates, Immer for deep nesting
Normalize state for relational data (byId + allIds pattern)
Never mutate state directly - use functional updates
Bail out of updates by returning same reference when unchanged
Apply structural sharing - only copy changed parts
Split state by update frequency to minimize re-renders
Avoid deep cloning (JSON stringify/parse) - use selective copying
6. Derived State and Computed Values
6.1 useMemo Hook for Expensive Calculations
useMemo Hook Syntax
Feature
Syntax
Description
Basic Syntax
const value = useMemo(() => computation, [deps])
Memoizes expensive calculation result
Return Value
Memoized value
Returns cached value if dependencies unchanged
Dependencies
[dep1, dep2, ...]
Recalculates only when dependencies change
Empty Deps
[]
Calculate once on mount
No Deps
undefined
⚠️ Recalculates every render (no memoization)
useMemo vs Recalculation Comparison
Aspect
Without useMemo
With useMemo
Calculation Timing
Every render
Only when dependencies change
Performance
May cause unnecessary work
Optimized for expensive operations
Memory
Lower memory overhead
Stores cached result in memory
Referential Equality
New reference each render
Same reference if deps unchanged
Use Case
Simple/cheap calculations
Expensive computations, reference stability
Example: Expensive Filtering with useMemo
const FilteredList = ({ items, filterText }) => { // ❌ Without useMemo - filters on every render const filteredItems = items.filter(item => item.name.toLowerCase().includes(filterText.toLowerCase()) ); // ✅ With useMemo - filters only when items or filterText change const memoizedItems = useMemo(() => { console.log('Filtering items...'); return items.filter(item => item.name.toLowerCase().includes(filterText.toLowerCase()) ); }, [items, filterText]); return <ul>{memoizedItems.map(item => <li key={item.id}>{item.name}</li>)}</ul>;};
Example: Complex Calculation with useMemo
const DataAnalysis = ({ data }) => { const statistics = useMemo(() => { const sum = data.reduce((acc, val) => acc + val, 0); const avg = sum / data.length; const sorted = [...data].sort((a, b) => a - b); const median = sorted[Math.floor(sorted.length / 2)]; return { sum, avg, median, count: data.length }; }, [data]); // Only recalculate when data changes return ( <div> <p>Sum: {statistics.sum}</p> <p>Average: {statistics.avg}</p> <p>Median: {statistics.median}</p> </div> );};
Note: Only use useMemo for genuinely expensive calculations. Simple operations (string
concatenation, basic math) don't benefit from memoization and add unnecessary complexity.
6.2 Derived State from Props and State Combinations
Anti-pattern Warning: Don't store derived values in state. This creates synchronization bugs
when source values change but derived state isn't updated.
Example: ❌ Bad - Storing Derived State
// ❌ BAD - fullName stored in state can get out of syncconst UserProfile = ({ initialFirstName, initialLastName }) => { const [firstName, setFirstName] = useState(initialFirstName); const [lastName, setLastName] = useState(initialLastName); const [fullName, setFullName] = useState(`${initialFirstName} ${initialLastName}`); // ❌ Must manually sync fullName - error-prone! const handleFirstNameChange = (e) => { setFirstName(e.target.value); setFullName(`${e.target.value} ${lastName}`); // Can forget this! }; return <input value={firstName} onChange={handleFirstNameChange} />;};// ✅ GOOD - Derive fullName from firstName and lastNameconst UserProfile = ({ initialFirstName, initialLastName }) => { const [firstName, setFirstName] = useState(initialFirstName); const [lastName, setLastName] = useState(initialLastName); // ✅ Always in sync - no manual updates needed const fullName = `${firstName} ${lastName}`; return <input value={firstName} onChange={(e) => setFirstName(e.target.value)} />;};
Note: For simple state selection, plain JavaScript is sufficient. Use libraries like Reselect
only when you need advanced memoization across multiple components.
Best Practice: Custom hooks for derived state improve code reusability, testability, and
maintainability. Extract complex derivation logic into custom hooks when it's used in multiple components.
6.5 Avoiding Derived State Anti-patterns
Common Derived State Anti-patterns
Anti-pattern
Problem
Solution
Storing Derived Data in State
Synchronization bugs, redundant state
Calculate derived values during render
useEffect to Sync Derived State
Unnecessary renders, complexity
Derive directly or use useMemo
Props in State (Derived from Props)
Stale values when props change
Use props directly or derive with key
Premature Memoization
Complexity without performance benefit
Profile first, optimize if needed
Over-normalization
Complex derivations for simple data
Keep data structure simple when possible
Anti-pattern Detection Checklist
Question
Red Flag (Anti-pattern)
Green Light (Good Pattern)
Can this be computed?
Using useState for computable value
Deriving value from existing state
Is useEffect updating state?
useEffect syncing derived value to state
Direct calculation or useMemo
Does state depend on props?
useState(props.value)
Use props directly or key prop reset
Is calculation expensive?
useMemo for trivial operations
Plain calculation or useMemo if profiled
Anti-pattern #1: Storing Derived Data in State
// ❌ BAD - Storing derived fullName in stateconst UserForm = () => { const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [fullName, setFullName] = useState(''); // ❌ Redundant state! // ❌ Must manually keep fullName in sync useEffect(() => { setFullName(`${firstName} ${lastName}`); }, [firstName, lastName]); // Extra render cycle! return <div>{fullName}</div>;};// ✅ GOOD - Derive fullName during renderconst UserForm = () => { const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); // ✅ Always in sync, no extra render const fullName = `${firstName} ${lastName}`; return <div>{fullName}</div>;};
Anti-pattern #2: Props in State (Mirror Props)
// ❌ BAD - Mirroring props in stateconst UserProfile = ({ user }) => { const [currentUser, setCurrentUser] = useState(user); // ❌ Stale when props change! // ❌ Requires useEffect to sync useEffect(() => { setCurrentUser(user); }, [user]); return <div>{currentUser.name}</div>;};// ✅ GOOD - Use props directlyconst UserProfile = ({ user }) => { return <div>{user.name}</div>;};// ✅ ALTERNATIVE - If you need to modify, derive initial state with keyconst UserProfile = ({ user }) => { const [edits, setEdits] = useState({}); // Derive current values from props + edits const currentName = edits.name ?? user.name; const currentEmail = edits.email ?? user.email; return ( <div> <input value={currentName} onChange={(e) => setEdits(prev => ({ ...prev, name: e.target.value }))} /> </div> );};// ✅ OR use key prop to reset state when user changesconst UserProfile = ({ user }) => { const [name, setName] = useState(user.name); return <input value={name} onChange={(e) => setName(e.target.value)} />;};// Usage with key<UserProfile key={user.id} user={user} /> // Remounts when user.id changes
Warning: Avoid infinite loops! Don't update a state variable inside useEffect that's also in
the dependency array without proper conditions. Always include all dependencies that are used inside the
effect to avoid stale closures.
Note: When syncing with external systems, always clean up subscriptions in the useEffect
cleanup function to prevent memory leaks. Use useSyncExternalStore for external stores in React 18+ to ensure
concurrent rendering compatibility.
Warning: Always return a cleanup function from useEffect when dealing with subscriptions,
timers, or async operations. Forgetting cleanup can lead to memory leaks, state updates on unmounted
components, and race conditions.
Best Practice: When working with subscriptions, use useCallback to memoize event handlers to
prevent unnecessary unsubscribe/resubscribe cycles. Always test that cleanup functions properly unsubscribe to
avoid memory leaks.
7.5 Race Condition Prevention in State Updates
Common Race Condition Scenarios
Scenario
Problem
Solution
Multiple Async Requests
Older request completes after newer one
AbortController, request ID tracking
Rapid State Changes
Intermediate states get overwritten
Debouncing, throttling, latest request wins
Stale Closure
Effect uses old state value
Functional state updates, proper dependencies
Unmounted Component Update
setState called after unmount
Cleanup flag, AbortSignal
Concurrent Requests
Multiple requests update same state
Request deduplication, cancel in-flight
Race Condition Prevention Techniques
Technique
Implementation
Use Case
AbortController
Cancel previous requests
Search, autocomplete, pagination
Request ID Tracking
Ignore responses from stale requests
Sequential requests with changing params
Cleanup Flag
Track if component is mounted
Prevent state updates after unmount
Debouncing
Delay request until input settles
Search input, form validation
Optimistic Updates
Update UI immediately, rollback on error
Form submissions, toggle actions
State Machines
Explicit state transitions
Complex async flows
Example: AbortController to Prevent Race Conditions
Race Condition Warning: Never rely on the order of async operations completing. Always use
AbortController, request ID tracking, or cleanup flags to ensure only the latest or relevant responses update
state. Test with artificial delays to expose race conditions during development.
State and Side Effects Best Practices Summary:
Always cleanup - Return cleanup function from useEffect for
subscriptions/timers
Abort requests - Use AbortController to cancel stale async requests
Track mount status - Prevent state updates on unmounted components
Debounce inputs - Reduce unnecessary API calls for rapid user input
Handle race conditions - Use request IDs or abort signals
Sync carefully - Be cautious with infinite loops in useEffect
Use proper deps - Include all values used in effect to avoid stale
closures
Test cleanup - Verify cleanup functions prevent memory leaks
8. Performance Optimization for State Management
8.1 React.memo and State Change Prevention
Technique
Syntax
Purpose
When to Use
React.memo
React.memo(Component)
Memoizes component, prevents re-render if props unchanged
Pure components receiving same props frequently
Custom comparison
React.memo(Comp, areEqual)
Custom equality function for props comparison
Complex props or specific comparison logic needed
Prop stability
Stable references with useCallback/useMemo
Ensures props don't change unnecessarily
When passing callbacks/objects as props to memoized components
Children pattern
{children} prop
Children don't re-render when parent state changes
Wrap expensive components in parent with local state
useCallback Pitfalls: Don't use useCallback everywhere - it has overhead. Only use when: (1)
Passing callback to memoized child component, (2) Callback is dependency of useEffect/useMemo, (3) Callback
creates expensive closure. Premature optimization can make code harder to read.
8.3 State Splitting and Component Granularity
Strategy
Implementation
Benefit
Use Case
Split state by concern
Multiple useState calls instead of one object
Components re-render only when relevant state changes
Independent state variables that update separately
Collocate state
Move state to component that uses it
Reduces parent re-renders, limits update scope
State only used by single child component
Lift state down
Push state to lowest common ancestor
Prevents sibling components from re-rendering
State shared by subset of children
Extract components
Separate stateful logic into smaller components
Isolates re-renders to affected component tree
Large components with multiple state updates
❌ Bad: Single state object causes unnecessary re-renders
function Form() { // All in one state const [formData, setFormData] = useState({ name: '', email: '', count: 0 }); // Every keystroke re-renders entire form // Even the counter that doesn't care about form inputs return ( <> <input value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} /> <ExpensiveCounter count={formData.count} /> </> );}
✅ Good: Split state by concern
function Form() { // Split independent state const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [count, setCount] = useState(0); // Typing in name only re-renders // name input, not counter return ( <> <input value={name} onChange={e => setName(e.target.value)} /> <ExpensiveCounter count={count} /> </> );}
Example: Collocating state for performance
// ❌ State in parent - all children re-renderfunction Parent() { const [isOpen, setIsOpen] = useState(false); return ( <> <Header /> {/* Re-renders on isOpen change */} <Sidebar isOpen={isOpen} setIsOpen={setIsOpen} /> <Content /> {/* Re-renders unnecessarily */} </> );}// ✅ State collocated in component that needs itfunction Parent() { return ( <> <Header /> {/* Never re-renders */} <Sidebar /> {/* Manages own state */} <Content /> {/* Never re-renders */} </> );}function Sidebar() { const [isOpen, setIsOpen] = useState(false); // State isolated to this component return <div>...</div>;}
8.4 useMemo for Expensive State Derivations
Pattern
Syntax
Purpose
Dependencies
useMemo
useMemo(() => computation, deps)
Memoizes computed value, recalculates only when deps change
Input values used in computation
Expensive calculation
useMemo(() => expensiveFn(data), [data])
Avoid re-computing on every render
Source data for calculation
Reference equality
useMemo(() => ({...obj}), [obj])
Maintain stable object/array reference
Primitive values that comprise object
Filtered/sorted data
useMemo(() => items.filter(...), [items])
Cache derived collections
Source collection and filter criteria
Example: useMemo for expensive computations
import { useState, useMemo } from 'react';function DataTable({ data, sortKey }) { const [filter, setFilter] = useState(''); // ❌ Recalculates on every render (even unrelated state changes) const badSortedData = data .filter(item => item.name.includes(filter)) .sort((a, b) => a[sortKey] - b[sortKey]); // ✅ Only recalculates when data, filter, or sortKey changes const sortedData = useMemo(() => { console.log('Expensive sort calculation'); return data .filter(item => item.name.includes(filter)) .sort((a, b) => a[sortKey] - b[sortKey]); }, [data, filter, sortKey]); // ✅ Stable object reference for props const tableConfig = useMemo(() => ({ rowHeight: 50, columnWidth: 100, virtualization: true }), []); // Empty deps - config never changes return <Table data={sortedData} config={tableConfig} />;}// Advanced: Memoize complex selectorfunction useFilteredItems(items, filters) { return useMemo(() => { return items.filter(item => { return Object.entries(filters).every(([key, value]) => { if (!value) return true; // Skip empty filters return item[key]?.toString().toLowerCase() .includes(value.toLowerCase()); }); }); }, [items, filters]); // Recalc when items or filters change}
When to use useMemo:
Expensive calculations - Heavy array operations, complex math, parsing
Reference equality matters - Object/array passed to memoized component or
as effect dependency
Don't optimize prematurely - Profile first, then optimize bottlenecks
Avoid for cheap operations - useMemo overhead can exceed benefit
8.5 Context Performance and Re-render Optimization
Technique
Implementation
Benefit
Trade-off
Split contexts
Separate frequently/infrequently changing data into different contexts
Components subscribe only to relevant data
More context providers to manage
Memoize context value
useMemo(() => ({state, actions}), [state])
Prevents context consumers re-rendering on every provider render
Must remember to memoize and manage deps
Context selectors
Custom hook to select subset of context
Component only re-renders when selected value changes
Requires additional abstraction layer
Component composition
Pass children before context provider
Children don't re-render on context changes
Limited to components that don't need context
❌ Bad: All consumers re-render
const AppContext = createContext();function Provider({ children }) { const [user, setUser] = useState(null); const [theme, setTheme] = useState('light'); // New object every render! const value = { user, setUser, theme, setTheme }; return ( <AppContext.Provider value={value}> {children} </AppContext.Provider> );}// All consumers re-render even if// they only use theme or only use user
Example: Context selector pattern for fine-grained subscriptions
import { createContext, useContext, useRef, useSyncExternalStore } from 'react';// Create store outside Reactfunction createStore(initialState) { let state = initialState; const listeners = new Set(); return { getState: () => state, setState: (newState) => { state = { ...state, ...newState }; listeners.forEach(listener => listener()); }, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); } };}const StoreContext = createContext(null);// Provider componentfunction StoreProvider({ children }) { const storeRef = useRef(); if (!storeRef.current) { storeRef.current = createStore({ count: 0, user: null, theme: 'light' }); } return ( <StoreContext.Provider value={storeRef.current}> {children} </StoreContext.Provider> );}// Selector hook - only re-renders when selected value changesfunction useStoreSelector(selector) { const store = useContext(StoreContext); return useSyncExternalStore( store.subscribe, () => selector(store.getState()), () => selector(store.getState()) );}// Usage - component only re-renders when count changes, not theme or userfunction Counter() { const count = useStoreSelector(state => state.count); const store = useContext(StoreContext); return ( <button onClick={() => store.setState({ count: count + 1 })}> {count} </button> );}
Context Performance Warning: Every component using useContext re-renders when
context value changes, even if using memo. Split contexts by update frequency and use selectors for
fine-grained subscriptions. Consider state management libraries (Zustand, Jotai) for complex scenarios.
8.6 State Update Batching and flushSync Usage
Concept
Behavior
React Version
Use Case
Automatic batching React 18
Multiple setState calls batched automatically in all contexts
React 18+
Default behavior - optimal for most cases
Legacy batching
Only batched in React event handlers (not timeouts, promises)
React 17 and earlier
Manual batching needed for async operations
flushSync()
Forces synchronous DOM update immediately
All versions
DOM measurements, third-party integrations
unstable_batchedUpdates
Manual batching for React 17
React 17 (deprecated in 18)
Legacy apps - not needed in React 18
Example: Automatic batching in React 18
import { useState } from 'react';function Counter() { const [count, setCount] = useState(0); const [flag, setFlag] = useState(false); console.log('Render'); // How many times does this log? // React 17: 3 renders (event handler, then 2 more in timeout) // React 18: 2 renders (batched in both event handler AND timeout) function handleClick() { setCount(c => c + 1); // Batched setFlag(f => !f); // Batched // Only 1 re-render for above updates setTimeout(() => { setCount(c => c + 1); // React 18: Batched ✅ setFlag(f => !f); // React 18: Batched ✅ // React 18: Only 1 re-render // React 17: 2 re-renders (not batched in timeout) }, 0); } return <button onClick={handleClick}>Click</button>;}// React 18 batches in:// - Event handlers ✅// - setTimeout/setInterval ✅// - Promise.then callbacks ✅// - Native event handlers ✅// - Any async code ✅
Example: flushSync for synchronous updates
import { useState, useRef } from 'react';import { flushSync } from 'react-dom';function ScrollExample() { const [items, setItems] = useState([1, 2, 3]); const listRef = useRef(null); function handleAdd() { // ❌ Without flushSync - scroll position won't be accurate setItems([...items, items.length + 1]); listRef.current.scrollTop = listRef.current.scrollHeight; // DOM hasn't updated yet, so scrollHeight is old value // ✅ With flushSync - forces immediate DOM update flushSync(() => { setItems([...items, items.length + 1]); }); // Now DOM is updated, scrollHeight is correct listRef.current.scrollTop = listRef.current.scrollHeight; } return ( <div ref={listRef} style={{ height: 200, overflow: 'auto' }}> {items.map(i => <div key={i}>Item {i}</div>)} <button onClick={handleAdd}>Add Item</button> </div> );}// Common flushSync use cases:// 1. DOM measurements (offsetHeight, scrollHeight, getBoundingClientRect)// 2. Focus management (element.focus())// 3. Third-party library integration (chart libraries, D3)// 4. Print dialogs (window.print())// 5. Selection APIs (window.getSelection())
flushSync Performance Warning:flushSync disables batching and forces synchronous
rendering, which hurts performance. Only use when you need immediate DOM updates for measurements or
third-party integrations. Overuse can cause performance degradation.
React 17 Manual Batching (Legacy):
import { unstable_batchedUpdates } from 'react-dom';// React 17 onlysetTimeout(() => { unstable_batchedUpdates(() => { setCount(c => c + 1); setFlag(f => !f); // Now batched in React 17 });}, 0);
Opt-out of Batching (React 18):
import { flushSync } from 'react-dom';function handleClick() { flushSync(() => { setCount(c => c + 1); }); // Render immediately flushSync(() => { setFlag(f => !f); }); // Render immediately again // 2 separate renders (not batched)}
Performance Optimization Summary:
React.memo - Prevent re-renders for pure components with stable props
useCallback - Memoize callbacks passed to memoized children or effect deps
useMemo - Cache expensive calculations and maintain reference equality
Split state - Separate independent state for granular re-renders
Collocate state - Keep state close to where it's used
Split contexts - Separate fast/slow changing data into different contexts
Context selectors - Subscribe to subset of context for fine-grained
updates
Automatic batching - React 18 batches all updates automatically
flushSync sparingly - Only for DOM measurements and third-party
integrations
Profile first - Use React DevTools Profiler before optimizing
9. Asynchronous State Management Patterns
9.1 Loading State Patterns and Boolean Flags
Pattern
State Structure
Use Case
Pros/Cons
Boolean flag
const [isLoading, setIsLoading]
Simple single async operation
✅ Simple, clear ❌ Can't track multiple operations
Status enum
'idle' | 'loading' | 'success' | 'error'
Single operation with multiple states
✅ Mutually exclusive states ❌ Verbose for multiple operations
Multiple flags
{isLoading, isRefreshing, isLoadingMore}
Different types of loading states
✅ Granular control ❌ Can have invalid combinations
Request ID tracking
const [loadingIds, setLoadingIds]
Multiple concurrent operations
✅ Track individual requests ❌ More complex state management
Sorting Gotcha: Array's .sort() method mutates the array! Always use
.slice().sort() or [...array].sort() to avoid mutating state. For large datasets
(>1000 items), consider server-side filtering/sorting or virtualization.
Virtual Scrolling: For lists with 10,000+ items, use libraries like react-window
or react-virtual. They only render visible items (+ buffer), drastically improving performance.
Example: 100,000 items but only render 20 visible = 5000x fewer DOM nodes.
Drag and Drop Complexity: Native HTML5 drag and drop has quirks and limited mobile support.
For production apps, consider libraries like react-beautiful-dnd, dnd-kit, or
react-dnd which handle edge cases, accessibility, and touch events.
Note: State machines prevent impossible states and clarify business logic. Use XState for
complex flows with nested states, parallel states, and visual tooling. For simpler cases, a custom useReducer
with strict action types may suffice.
13.2 Undo/Redo State Management with History Stack
Pattern
Implementation
Description
Considerations
History Stack
{ past: [], present, future: [] }
Three arrays tracking state timeline
past = previous states, present = current, future = undone states
Undo Operation
past.pop() → present, present → future
Move current to future, restore from past
Disable when past is empty
Redo Operation
future.pop() → present, present → past
Move current to past, restore from future
Disable when future is empty
New Action
present → past, clear future
Save current to past, clear redo stack
Any new action invalidates future (redo)
Memory Limit
past.slice(-limit)
Keep only N recent states to prevent memory bloat
Typical limit: 50-100 states depending on data size
Example: Debounced history for text input (grouped actions)
function useUndoableText(initialText) { const { state, set, undo, redo, canUndo, canRedo } = useUndoable(initialText); const [tempText, setTempText] = useState(state); // Save to history after 500ms of no typing useEffect(() => { if (tempText !== state) { const timeout = setTimeout(() => set(tempText), 500); return () => clearTimeout(timeout); } }, [tempText, state, set]); return { text: tempText, setText: setTempText, undo: () => { setTempText(state); undo(); }, redo: () => { redo(); setTempText(state); }, canUndo, canRedo };}
Warning: Deep cloning large state objects can be expensive. Consider storing diffs/patches
instead of full snapshots for large datasets. Libraries like Immer can help with structural sharing.
13.3 Optimistic UI Updates and Rollback Patterns
Pattern
Implementation
Description
Use Case
Optimistic Update
updateUI() → API call
Update UI immediately before server confirms
Like buttons, post creation, todo completion - instant feedback
Note: Optimistic updates improve perceived performance but require careful error handling.
Always show pending state visually, provide rollback mechanisms, and handle conflicts gracefully. Use version
numbers or ETags to detect stale updates.
13.4 State Normalization for Relational Data
Concept
Structure
Benefits
Implementation
Normalized State
{ entities: {}, ids: [] }
Flat structure, no duplication, easy updates
Store entities in objects keyed by ID, separate array of IDs for order
Entities by ID
{ users: { '1': {...}, '2': {...} } }
O(1) lookups, no array iteration
Object/Map keyed by entity ID
ID Arrays
{ userIds: ['1', '2', '3'] }
Preserve order, maintain relationships
Separate arrays for ordering and filtering
Relationships
{ postId: '123', authorId: '456' }
Reference by ID instead of nesting
Store IDs instead of nested objects
Selectors
selectUser(state, id)
Derive denormalized data for views
Functions to join normalized data for rendering
normalizr Library
normalize(data, schema)
Automatic normalization from API responses
Define entity schemas, normalize nested JSON
Redux Toolkit
createEntityAdapter()
Built-in CRUD reducers for normalized state
Auto-generates selectors and reducers for entities
Note: Normalize when you have relational data, frequent updates, or need to display same
entity in multiple places. Denormalize in selectors for rendering. Libraries like normalizr and Redux Toolkit
simplify normalization significantly.
13.5 Event Sourcing Patterns in React State
Concept
Description
Implementation
Benefits
Event Sourcing
Store sequence of events instead of current state
events = [{ type, payload, timestamp }]
Complete audit trail, time travel, replay capability
Event Store
Append-only log of all events
const events = useState([])
Immutable history, never delete/modify events
Event Replay
Reconstruct state by replaying events
events.reduce(reducer, initialState)
Derive current state from event history
Projections
Different views of same event stream
Multiple reducers consuming same events
Create multiple state representations from one source
Snapshots
Cached state at specific points in time
{ snapshot, eventsAfter }
Performance optimization, avoid replaying all events
Warning: Event sourcing increases complexity and memory usage. Use snapshots to limit event
replay overhead. Consider persisting events to backend for durability. Best for audit-critical applications
like financial systems, collaborative editors, or analytics platforms.
Note: CQRS adds complexity but enables independent scaling and optimization of reads vs writes.
Use when read/write patterns differ significantly or when you need specialized views of the same data. Often
combined with event sourcing for complete audit trails.
Section 13 Key Takeaways
State machines prevent impossible states with explicit transitions - use
XState for complex flows
Undo/redo requires history stack (past/present/future) with memory limits
and action grouping
Optimistic updates improve UX but need rollback, pending states, and
conflict resolution
Normalization eliminates duplication, enables O(1) updates - use for
relational data
Event sourcing stores events not state - enables time travel, audit trails,
multiple projections
CQRS separates read/write models for independent optimization and scaling
14. External State Management Library Integration
14.1 Redux Toolkit (RTK) Integration Patterns
Feature
API
Description
Use Case
configureStore
configureStore({ reducer })
Create Redux store with good defaults (DevTools, middleware)
Single store setup with automatic configuration
createSlice
createSlice({ name, initialState, reducers })
Generate action creators and reducers automatically
Reduce boilerplate, use Immer for immutability
createAsyncThunk
createAsyncThunk('name', async (arg) => ...)
Handle async logic with pending/fulfilled/rejected actions
API calls with automatic loading/error state
createEntityAdapter
createEntityAdapter()
Normalized state with CRUD reducers and selectors
Managing collections of entities (users, posts, etc.)
createSelector
createSelector([input], (data) => result)
Memoized selectors for derived state
Compute derived data without re-renders
RTK Query
createApi({ endpoints })
Data fetching and caching solution
Replace manual async thunks with auto-caching API layer
Note: Redux Toolkit is the recommended way to use Redux. It includes Immer for immutable
updates, Redux Thunk for async, and Redux DevTools by default. Use RTK Query for data fetching instead of
manually writing thunks.
14.2 Zustand Store Implementation and Usage
Feature
API
Description
Benefits
create
create((set, get) => ({ ... }))
Create a hook-based store
No providers, minimal boilerplate, auto-generates hooks
Note: Zustand is lightweight (~1KB), requires no Provider, and supports TypeScript excellently.
Use selectors to optimize re-renders. Combine with middleware for persistence, DevTools, and Immer integration.
14.3 Jotai Atomic State Management
Concept
API
Description
Use Case
Atom
atom(initialValue)
Primitive unit of state (like React state)
Small, independent pieces of state
useAtom
const [value, setValue] = useAtom(atom)
Read and write atom value
useState-like API for atoms
useAtomValue
const value = useAtomValue(atom)
Read-only access to atom
Subscribe to value without write capability
useSetAtom
const setValue = useSetAtom(atom)
Write-only access to atom
Update without subscribing to value
Derived Atoms
atom((get) => get(otherAtom) * 2)
Compute value from other atoms
Derived/computed state with automatic dependencies
Note: Jotai provides bottom-up atomic state management. Atoms are defined globally but values
are stored per Provider scope. Excellent TypeScript support, built-in Suspense integration, and minimal
boilerplate.
14.4 Valtio Proxy-based State Management
Feature
API
Description
Characteristics
proxy
const state = proxy({ ... })
Create mutable proxy state object
Mutate directly, auto-detects changes with Proxies
Note: Valtio enables mutable-style updates with immutable snapshots for React. Use for
TypeScript projects needing simple, direct mutations. Automatic fine-grained reactivity without manual
selectors. Consider Vue-like developer experience.
import { atom, selector, selectorFamily, atomFamily } from 'recoil';// Async selector for current userconst currentUserQuery = selector({ key: 'currentUserQuery', get: async () => { const response = await fetch('/api/current-user'); return response.json(); }});// Atom family for user data by IDconst userState = atomFamily({ key: 'userState', default: selectorFamily({ key: 'userState/default', get: (userId) => async () => { const response = await fetch(`/api/users/${userId}`); return response.json(); } })});// Selector family for user postsconst userPostsQuery = selectorFamily({ key: 'userPostsQuery', get: (userId) => async ({ get }) => { // Can depend on other atoms/selectors const user = get(userState(userId)); const response = await fetch(`/api/users/${userId}/posts`); return response.json(); }});// Component usage with Suspenseimport { Suspense } from 'react';function CurrentUser() { const user = useRecoilValue(currentUserQuery); return <div>{user.name}</div>;}function UserProfile({ userId }) { const user = useRecoilValue(userState(userId)); const posts = useRecoilValue(userPostsQuery(userId)); return ( <div> <h1>{user.name}</h1> <ul> {posts.map(p => <li key={p.id}>{p.title}</li>)} </ul> </div> );}function App() { return ( <RecoilRoot> <Suspense fallback={<div>Loading...</div>}> <CurrentUser /> <UserProfile userId={1} /> </Suspense> </RecoilRoot> );}
Warning: Recoil is experimental and development has slowed. Consider Jotai as a more actively
maintained alternative with similar atomic state patterns. Recoil requires unique string keys for all atoms
which can be cumbersome.
14.6 Custom State Management Library Creation
Approach
Implementation
Complexity
Features
useSyncExternalStore
useSyncExternalStore(subscribe, getSnapshot)
Low - React 18 built-in
Sync external state with React, concurrent-safe
Context + useReducer
createContext + Provider
Low - Uses React primitives
Simple global state, requires Provider wrapping
Observable Pattern
subscribe/unsubscribe listeners
Medium - Manual subscription management
Pub/sub system, works outside React
Proxy-based
new Proxy(target, handler)
Medium - Requires Proxy knowledge
Auto-tracking, mutable-style updates
Immer Integration
produce(state, draft => ...)
Low - Library dependency
Immutable updates with mutable syntax
Middleware Support
compose(middleware1, middleware2)
High - Complex composition
Extensibility via plugins (logging, persist, devtools)
Note: Custom stores are useful for learning or specific needs. Use useSyncExternalStore (React
18+) for concurrent-safe external state. For production, prefer established libraries (Zustand, Jotai) unless
you have unique requirements.
Section 14 Key Takeaways
Redux Toolkit - Official Redux with minimal boilerplate, Immer, and RTK
Query for data fetching
Zustand - Lightweight (1KB), no Provider, hook-based with middleware
support
Jotai - Atomic bottom-up state, Suspense support, minimal API similar to
Recoil
Valtio - Proxy-based mutable updates with immutable snapshots, fine-grained
reactivity
Recoil - Experimental atomic state from Meta, consider Jotai as
alternative
Custom stores - Use useSyncExternalStore for concurrent-safe external
state integration
Note: React Query excels at server state management with automatic caching, background updates,
and deduplication. Use query keys as dependencies - when they change, queries refetch. Combine with optimistic
updates for instant UX.
Note: SWR is lightweight (~5KB) with excellent TypeScript support. The "stale-while-revalidate"
strategy shows cached data instantly while fetching fresh data in background. Great for Next.js apps with
built-in integration.
Note: Apollo Client provides comprehensive GraphQL state management with normalized caching,
optimistic updates, and local state. Automatic cache updates for many cases. Use reactive variables for UI
state alongside server data.
15.4 Server State vs Client State Separation
State Type
Characteristics
Management
Examples
Server State
Persisted remotely, asynchronous, shared, can be stale
React Query, SWR, Apollo Client
User data, posts, products, API responses
Client State
Local to browser, synchronous, not shared, always fresh
useState, useReducer, Context, Zustand
UI state, form inputs, modals, theme, filters
Hybrid State
Derived from server, modified locally
Combine both approaches
Filtered/sorted server data, draft edits
Ownership
Server owns server state, client owns UI state
Clear boundaries prevent conflicts
Don't duplicate server data in client state
Persistence
Server state persists, client state ephemeral
Server state survives refreshes
User profile (server) vs sidebar open (client)
Sync Strategy
Server state needs sync, client state doesn't
Polling, websockets, invalidation
Real-time updates, stale data handling
Cache Strategy
Server state cached, client state in memory
Different cache policies
Stale-while-revalidate vs direct access
Error Handling
Server state can fail, client state reliable
Network errors, retries, fallbacks
Loading/error states for server data
Example: Clear separation of server and client state
Example: Anti-pattern - duplicating server state in client state
// ❌ ANTI-PATTERN - Don't do thisfunction BadExample() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { setLoading(true); fetch('/api/users') .then(res => res.json()) .then(data => { setUsers(data); // Duplicating server data setLoading(false); }); }, []); // Problem: No caching, no auto-refetch, manual loading state return <div>...</div>;}// ✅ BETTER - Use server state libraryfunction GoodExample() { const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: fetchUsers }); // Automatic caching, refetching, error handling return <div>...</div>;}// ✅ CORRECT - Client state for UI, server state for datafunction BestExample() { // Server state const { data: users } = useQuery(['users'], fetchUsers); // Client state const [selectedId, setSelectedId] = useState(null); const [sortOrder, setSortOrder] = useState('asc'); // Derive combined state const sortedUsers = useMemo(() => { if (!users) return []; return [...users].sort((a, b) => sortOrder === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name) ); }, [users, sortOrder]); return <div>...</div>;}
Warning: Don't duplicate server state in client state (useState/Redux). Use specialized
libraries (React Query/SWR) for server data. Keep client state for UI concerns only. This prevents sync issues
and reduces code complexity.
import { useQueryClient } from '@tanstack/react-query';import { useEffect } from 'react';function useRealtimeSync() { const queryClient = useQueryClient(); useEffect(() => { const socket = new WebSocket('ws://api.example.com'); socket.onmessage = (event) => { const message = JSON.parse(event.data); switch (message.type) { case 'USER_CREATED': // Invalidate user list queryClient.invalidateQueries({ queryKey: ['users'] }); break; case 'USER_UPDATED': // Update specific user queryClient.setQueryData( ['users', message.userId], message.data ); // Also update in list queryClient.setQueryData(['users', 'list'], (old) => old.map(u => u.id === message.userId ? message.data : u ) ); break; case 'USER_DELETED': // Remove from cache queryClient.removeQueries({ queryKey: ['users', message.userId] }); queryClient.setQueryData(['users', 'list'], (old) => old.filter(u => u.id !== message.userId) ); break; } }; return () => socket.close(); }, [queryClient]);}function App() { useRealtimeSync(); return <UserDashboard />;}
Note: Cache invalidation is hard but crucial for data consistency. Use invalidateQueries after
mutations, implement optimistic updates for instant feedback, and consider WebSockets for real-time apps. Always
provide rollback mechanisms.
15.6 Offline State Management with Service Workers
Pattern
Implementation
Description
Benefits
Service Worker Cache
caches.match(), caches.put()
Cache API responses for offline access
Work without network, instant loading
Cache Strategies
Cache First, Network First, Stale While Revalidate
Different strategies for different resources
Balance freshness vs offline availability
Background Sync
registration.sync.register()
Queue actions while offline, sync when online
Reliable data submission despite connectivity
IndexedDB Queue
store pending mutations in IndexedDB
Persist failed requests locally
Survive page refreshes, manual retry
Online/Offline Detection
navigator.onLine, online/offline events
Track network status
Adapt UI based on connectivity
Conflict Resolution
Last-write-wins, merge, manual
Handle concurrent offline edits
Data integrity when syncing
Version Vectors
Track edit history per client
Detect conflicts in distributed edits
Sophisticated conflict detection
Optimistic Sync
Update UI immediately, sync in background
Assume success, handle failures gracefully
Responsive offline-first UX
Example: Offline-first with React Query and service worker
Warning: Offline-first apps require careful conflict resolution. Consider using CRDTs
(Conflict-free Replicated Data Types) for complex scenarios. Always show offline indicators and sync status to
users. Test thoroughly with throttled/offline network conditions.
Section 15 Key Takeaways
React Query - Powerful server state with caching, refetching, optimistic
updates, and pagination
SWR - Lightweight stale-while-revalidate pattern, great for Next.js,
simple API
Apollo Client - GraphQL state management with normalized cache and local
state
Separation - Keep server state (React Query/SWR) separate from client state
(Zustand/Context)
Cache invalidation - Use after mutations, implement optimistic updates,
WebSockets for real-time
Note: useTransition keeps input responsive by deprioritizing expensive updates. Use for
filtering, searching, tab switching, or any CPU-intensive state changes. The isPending flag lets you show
loading states or reduce opacity during transitions.
16.2 useDeferredValue for State Value Deferring
Feature
API
Description
Difference from useTransition
useDeferredValue
const deferredValue = useDeferredValue(value)
Defer rendering with stale value
For values you don't control (props), useTransition for state you own
Note: useDeferredValue is ideal when you receive a frequently changing value from props. Use
with memo() to prevent re-renders until deferred value updates. For state you control directly, prefer
useTransition with startTransition.
16.3 Concurrent Rendering and State Consistency
Concept
Description
Implications
Best Practices
Concurrent Rendering
React can pause, resume, or abandon renders
Renders may happen multiple times before commit
Render functions must be pure, no side effects
Tearing
Different parts showing different state snapshots
Can occur with external stores in concurrent mode
Use useSyncExternalStore to prevent tearing
State Snapshots
Each render gets consistent state snapshot
State doesn't change mid-render
Rely on this for consistent derived values
Automatic Batching
All state updates batched (even in async)
Fewer re-renders in React 18
No manual batching needed in most cases
flushSync
flushSync(() => setState(...))
Force synchronous update (opt-out batching)
Use sparingly, only when absolutely needed
Strict Mode
Double-invokes functions in development
Catches impure render functions
Keep renders pure, no mutations or side effects
Time Slicing
Long renders split into chunks
Browser stays responsive during heavy work
Enabled automatically with transitions
Work Prioritization
High priority work interrupts low priority
User interactions stay responsive
Mark background updates as transitions
Example: Automatic batching in React 18
import { useState } from 'react';import { flushSync } from 'react-dom';function Counter() { const [count, setCount] = useState(0); const [flag, setFlag] = useState(false); console.log('Render count:', count, 'flag:', flag); // React 18: Single re-render (batched) const handleClick = () => { setCount(c => c + 1); setFlag(f => !f); // Only ONE render, not two }; // React 18: Even async updates are batched const handleAsync = async () => { await fetch('/api/data'); setCount(c => c + 1); setFlag(f => !f); // Still only ONE render! }; // React 18: Timeouts also batched const handleTimeout = () => { setTimeout(() => { setCount(c => c + 1); setFlag(f => !f); // Still batched! }, 1000); }; // Opt-out of batching with flushSync (rarely needed) const handleFlushSync = () => { flushSync(() => { setCount(c => c + 1); // Render immediately }); setFlag(f => !f); // Second render // TWO renders }; return ( <div> <p>Count: {count}, Flag: {flag ? 'true' : 'false'}</p> <button onClick={handleClick}>Sync Update</button> <button onClick={handleAsync}>Async Update</button> <button onClick={handleTimeout}>Timeout Update</button> <button onClick={handleFlushSync}>FlushSync (not batched)</button> </div> );}
Example: Pure render functions for concurrent mode
import { useState } from 'react';// ❌ IMPURE - Will cause issues in concurrent modelet renderCount = 0;function ImpureComponent() { renderCount++; // Side effect during render! const [state, setState] = useState(0); // Double counting in StrictMode/concurrent renders return <div>Renders: {renderCount}</div>;}// ✅ PURE - Safe for concurrent modefunction PureComponent() { const [state, setState] = useState(0); const [renderCount, setRenderCount] = useState(0); // Track renders in effect, not during render useEffect(() => { setRenderCount(c => c + 1); }); return <div>Renders: {renderCount}</div>;}// ❌ IMPURE - Mutating external objectconst cache = { data: null };function MutatingComponent({ id }) { cache.data = fetchData(id); // Mutation during render! return <div>{cache.data}</div>;}// ✅ PURE - Use state or refs for cachingfunction NonMutatingComponent({ id }) { const [data, setData] = useState(null); useEffect(() => { fetchData(id).then(setData); }, [id]); return <div>{data}</div>;}// ✅ PURE - Derived values calculated during renderfunction DerivedComponent({ items }) { // Pure computation - no side effects const total = items.reduce((sum, item) => sum + item.price, 0); const average = total / items.length; return <div>Average: {average}</div>;}
Warning: Concurrent rendering can call render functions multiple times before committing. Keep
renders pure - no mutations, no side effects, no external state changes. Use useEffect for side effects, not
render phase.
16.4 useSyncExternalStore for External State Integration
Feature
API
Description
Purpose
useSyncExternalStore
useSyncExternalStore(subscribe, getSnapshot)
Subscribe to external stores safely
Prevent tearing in concurrent mode
subscribe
(callback) => unsubscribe
Register listener for store changes
React re-renders when store updates
getSnapshot
() => currentValue
Return current store value
Must return same value for same store state
getServerSnapshot
() => serverValue
Optional server-side value
SSR hydration without mismatch
Tearing Prevention
Ensures consistent state across tree
All components see same snapshot
Critical for concurrent rendering
External Stores
Redux, Zustand, custom stores, browser APIs
Any non-React state source
Safe integration with React 18
Selector Function
getSnapshot: () => store.getState().slice
Select specific state slice
Optimize re-renders, only subscribe to needed data
Note: useSyncExternalStore is essential for library authors and when integrating external
stores (Redux, browser APIs). It prevents tearing in concurrent mode by ensuring all components read the same
snapshot. Most app developers won't use it directly.
Note: React 18's priority system ensures user interactions always feel responsive. Mark
expensive updates as transitions, keep direct user feedback (clicks, typing) as urgent. The scheduler handles
the rest automatically.
16.6 Streaming SSR and State Hydration
Feature
Description
Benefits
Considerations
Streaming SSR
Send HTML in chunks as components render
Faster TTFB, progressive page load
Requires React 18 + supporting framework
Selective Hydration
Hydrate components on-demand
Interactive sooner, prioritize visible content
User interactions trigger hydration
Suspense SSR
<Suspense> works on server
Stream fallback, replace when ready
Slow components don't block entire page
Progressive Hydration
Hydrate critical parts first
Above-fold content interactive immediately
Below-fold waits until needed
State Serialization
Serialize server state to HTML
Client picks up where server left off
Avoid hydration mismatches
Hydration Mismatch
Server/client HTML differs
Console warnings, potential bugs
Use same data, avoid client-only code in render
useId
const id = useId()
Generate stable IDs for SSR
Prevents mismatch from random IDs
Partial Hydration
Some components remain static
Save JS bundle size and hydration time
Mark non-interactive content as static
Example: Streaming SSR with Suspense boundaries
import { Suspense } from 'react';// Server-side rendering with streamingfunction App() { return ( <html> <body> {/* Critical content - renders first */} <Header /> <Navigation /> {/* Slow component - streams later */} <Suspense fallback={<SkeletonComments />}> <Comments /> {/* Fetches data on server */} </Suspense> {/* Another slow component */} <Suspense fallback={<SkeletonRecommendations />}> <Recommendations /> </Suspense> <Footer /> </body> </html> );}// Component with async data fetchingasync function Comments() { const comments = await fetchComments(); // Suspends return ( <div> {comments.map(c => ( <Comment key={c.id} data={c} /> ))} </div> );}// Streaming sequence:// 1. Server sends: Header, Nav, SkeletonComments, SkeletonRecs, Footer// 2. Page visible immediately with skeletons// 3. Comments finish -> streamed, replace skeleton// 4. Recommendations finish -> streamed, replace skeleton// 5. Progressive hydration makes interactive on-demand
Warning: Hydration mismatches cause React to discard server HTML and re-render on client
(expensive). Avoid Date.now(), Math.random(), window/document in render. Use useEffect for client-only code and
useId for stable IDs.
Section 16 Key Takeaways
useTransition - Keep input responsive during expensive updates, mark
non-urgent updates as transitions
useDeferredValue - Defer expensive renders based on prop values, similar
to debouncing
Concurrent rendering - Renders can pause/resume, keep renders pure, no side
effects
useSyncExternalStore - Prevent tearing when integrating external stores in
concurrent mode
Note: Test reducer functions separately from components for easier debugging and better test
coverage. Pure reducer functions don't require React Testing Library.
17.3 Testing Context Providers and Consumer Components
Test context providers supply correct values and consumers react to context changes.
Testing Pattern
Description
Use Case
Custom wrapper
Wrap components with providers
Test components consuming context
Mock context values
Provide test-specific values
Control context state in tests
Context updates
Test context value changes
Verify consumer re-renders
Multiple consumers
Test multiple context users
Shared state consistency
Example: Testing Context Provider and Consumer
// ThemeContext.jsx
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// ThemeDisplay.jsx
export function ThemeDisplay() {
const { theme, toggleTheme } = useTheme();
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
);
}
// ThemeContext.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, useTheme } from './ThemeContext';
import { ThemeDisplay } from './ThemeDisplay';
describe('ThemeContext', () => {
test('provides default theme value', () => {
render(
<ThemeProvider>
<ThemeDisplay />
</ThemeProvider>
);
expect(screen.getByText('Current theme: light')).toBeInTheDocument();
});
test('toggles theme when button clicked', async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<ThemeDisplay />
</ThemeProvider>
);
const toggleBtn = screen.getByRole('button', { name: /toggle theme/i });
await user.click(toggleBtn);
expect(screen.getByText('Current theme: dark')).toBeInTheDocument();
await user.click(toggleBtn);
expect(screen.getByText('Current theme: light')).toBeInTheDocument();
});
test('throws error when useTheme used outside provider', () => {
// Suppress console.error for this test
const consoleError = jest.spyOn(console, 'error').mockImplementation();
const TestComponent = () => {
useTheme();
return null;
};
expect(() => render(<TestComponent />)).toThrow(
'useTheme must be used within ThemeProvider'
);
consoleError.mockRestore();
});
test('multiple consumers share same context', async () => {
const user = userEvent.setup();
function MultipleDisplays() {
return (
<ThemeProvider>
<ThemeDisplay />
<ThemeDisplay />
</ThemeProvider>
);
}
render(<MultipleDisplays />);
const displays = screen.getAllByText('Current theme: light');
expect(displays).toHaveLength(2);
const toggleBtn = screen.getAllByRole('button', { name: /toggle theme/i })[0];
await user.click(toggleBtn);
const darkDisplays = screen.getAllByText('Current theme: dark');
expect(darkDisplays).toHaveLength(2);
});
});
Warning: Mocking React hooks can lead to brittle tests. Prefer integration testing with real
hooks when possible. Use mocking primarily for external dependencies or complex state scenarios.
17.5 Integration Testing with State Management Libraries
Test complete state management flows including store initialization, actions, and selectors.
Note: Create fresh store/client instances for each test to ensure test isolation. Configure
libraries to disable retries and caching behaviors that might cause flaky tests.
17.6 End-to-End State Testing with Cypress/Playwright
Test complete user workflows including state persistence, navigation, and multi-page state management.
E2E Pattern
Description
Use Case
User flows
Test complete user journeys
Login, checkout, multi-step forms
State persistence
Verify localStorage/sessionStorage
Shopping cart, user preferences
Navigation state
Test state across page transitions
Wizard forms, checkout flows
API interactions
Test real/mocked API calls
Data fetching, mutations
Visual regression
Test UI based on state changes
Theme switching, conditional rendering
Example: Cypress E2E State Testing
// cypress/e2e/shopping-cart.cy.js
describe('Shopping Cart State Management', () => {
beforeEach(() => {
cy.visit('/products');
cy.clearLocalStorage();
});
it('adds products to cart and persists state', () => {
// Add first product
cy.get('[data-testid="product-1"]').within(() => {
cy.contains('Add to Cart').click();
});
// Verify cart count updated
cy.get('[data-testid="cart-count"]').should('contain', '1');
// Add second product
cy.get('[data-testid="product-2"]').within(() => {
cy.contains('Add to Cart').click();
});
cy.get('[data-testid="cart-count"]').should('contain', '2');
// Verify localStorage persisted cart state
cy.window().then(win => {
const cartState = JSON.parse(win.localStorage.getItem('cart'));
expect(cartState.items).to.have.length(2);
});
// Navigate to cart page
cy.get('[data-testid="view-cart"]').click();
cy.url().should('include', '/cart');
// Verify cart items displayed
cy.get('[data-testid="cart-item"]').should('have.length', 2);
});
it('persists cart across page refresh', () => {
// Add product to cart
cy.get('[data-testid="product-1"]').within(() => {
cy.contains('Add to Cart').click();
});
// Reload page
cy.reload();
// Verify cart persisted
cy.get('[data-testid="cart-count"]').should('contain', '1');
});
it('clears cart on logout', () => {
// Setup: Login and add items
cy.login('test@example.com', 'password');
cy.get('[data-testid="product-1"]').within(() => {
cy.contains('Add to Cart').click();
});
// Logout
cy.get('[data-testid="logout"]').click();
// Verify cart cleared
cy.get('[data-testid="cart-count"]').should('contain', '0');
cy.window().then(win => {
expect(win.localStorage.getItem('cart')).to.be.null;
});
});
it('syncs cart across multiple tabs', () => {
// Add to cart in first tab
cy.get('[data-testid="product-1"]').within(() => {
cy.contains('Add to Cart').click();
});
// Open new tab (simulate with new visit)
cy.visit('/products');
// Verify cart synced
cy.get('[data-testid="cart-count"]').should('contain', '1');
});
});
Example: Playwright E2E State Testing
// tests/auth-state.spec.js
import { test, expect } from '@playwright/test';
test.describe('Authentication State Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('maintains auth state after login', async ({ page }) => {
// Fill login form
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="login-button"]');
// Wait for redirect
await page.waitForURL('/dashboard');
// Verify auth state in UI
await expect(page.locator('[data-testid="user-name"]')).toHaveText('Test User');
// Verify auth token in localStorage
const token = await page.evaluate(() => localStorage.getItem('authToken'));
expect(token).toBeTruthy();
// Navigate to different page
await page.goto('/profile');
// Verify still authenticated
await expect(page.locator('[data-testid="user-name"]')).toHaveText('Test User');
});
test('redirects to login when accessing protected route without auth', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForURL('/login');
await expect(page.locator('h1')).toHaveText('Login');
});
test('clears auth state on logout', async ({ page, context }) => {
// Login first
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="login-button"]');
await page.waitForURL('/dashboard');
// Logout
await page.click('[data-testid="logout-button"]');
await page.waitForURL('/login');
// Verify auth state cleared
const token = await page.evaluate(() => localStorage.getItem('authToken'));
expect(token).toBeNull();
// Verify redirected when trying to access protected route
await page.goto('/dashboard');
await page.waitForURL('/login');
});
test('handles concurrent auth state in multiple tabs', async ({ context }) => {
// Create two pages (tabs)
const page1 = await context.newPage();
const page2 = await context.newPage();
// Login in first tab
await page1.goto('/login');
await page1.fill('[data-testid="email-input"]', 'test@example.com');
await page1.fill('[data-testid="password-input"]', 'password123');
await page1.click('[data-testid="login-button"]');
await page1.waitForURL('/dashboard');
// Navigate in second tab
await page2.goto('/dashboard');
// Verify authenticated in both tabs
await expect(page1.locator('[data-testid="user-name"]')).toHaveText('Test User');
await expect(page2.locator('[data-testid="user-name"]')).toHaveText('Test User');
// Logout in first tab
await page1.click('[data-testid="logout-button"]');
// Verify second tab also logged out (if cross-tab sync implemented)
await page2.reload();
await page2.waitForURL('/login');
});
});
Note: E2E tests are slower and more expensive than unit tests. Focus on critical user flows
and state persistence scenarios. Use API mocking strategically to improve test speed and reliability.
Section 17 Key Takeaways
React Testing Library - Test state logic through user interactions, use
userEvent for realistic testing
Reducer testing - Test reducer functions in isolation as pure functions,
then integration test with components
Context testing - Create custom render utilities with providers, test
error handling when context missing
State mocking - Mock external state libraries, prefer real hooks when
possible to avoid brittle tests
Integration testing - Use real store instances in tests, clear state
between tests for isolation
E2E testing - Test complete user flows including state persistence,
navigation, and cross-tab synchronization
18. State Debugging and Development Tools
18.1 React DevTools for State Inspection
React DevTools provides powerful state inspection, component hierarchy visualization, and performance
profiling capabilities.
Example: Using DevTools Component Displayname for Better Debugging
// Add display names to components for better DevTools experience
import { useState, memo } from 'react';
// Functional component with display name
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// ... state logic
return <div>{user?.name}</div>;
}
UserProfile.displayName = 'UserProfile';
// Memoized component with display name
const MemoizedItem = memo(function Item({ item }) {
return <div>{item.name}</div>;
});
MemoizedItem.displayName = 'MemoizedItem';
// Custom hook - shows in DevTools hooks section
function useDebugValue(value, formatFn) {
// useDebugValue shows custom label in DevTools
useDebugValue(value, v => `User: ${v?.name || 'None'}`);
return value;
}
// Higher-order component with proper naming
function withLoading(Component) {
function WithLoading(props) {
const [loading, setLoading] = useState(true);
// ... loading logic
return loading ? <div>Loading...</div> : <Component {...props} />;
}
WithLoading.displayName = `WithLoading(${Component.displayName || Component.name})`;
return WithLoading;
}
// Usage in DevTools:
// - Components show meaningful names instead of "Anonymous"
// - Custom hooks display formatted debug values
// - HOCs show wrapped component name for easier debugging
Note: Enable "Highlight updates when components render" in DevTools settings to visually see
which components re-render on state changes. This helps identify performance issues.
18.2 Redux DevTools Extension Integration
Redux DevTools Extension provides time-travel debugging, action history, and state diff visualization for
Redux applications.
Example: Custom Redux DevTools Integration for Non-Redux State
// Custom hook with Redux DevTools integration
import { useReducer, useEffect, useRef } from 'react';
function useReducerWithDevTools(reducer, initialState, name = 'State') {
const [state, dispatch] = useReducer(reducer, initialState);
const devTools = useRef(null);
useEffect(() => {
// Connect to Redux DevTools Extension
if (typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__) {
devTools.current = window.__REDUX_DEVTOOLS_EXTENSION__.connect({
name,
features: { jump: true, skip: true }
});
// Send initial state
devTools.current.init(initialState);
}
return () => {
if (devTools.current) {
devTools.current.unsubscribe();
}
};
}, [name, initialState]);
// Wrapper dispatch that notifies DevTools
const dispatchWithDevTools = (action) => {
dispatch(action);
if (devTools.current) {
// Send action to DevTools
devTools.current.send(action, reducer(state, action));
}
};
// Subscribe to DevTools messages (time travel)
useEffect(() => {
if (!devTools.current) return;
const unsubscribe = devTools.current.subscribe((message) => {
if (message.type === 'DISPATCH' && message.state) {
// Handle time-travel: update state from DevTools
const newState = JSON.parse(message.state);
// Note: This requires modifying the reducer to accept a SET_STATE action
dispatch({ type: '@@DEVTOOLS/SET_STATE', payload: newState });
}
});
return unsubscribe;
}, []);
return [state, dispatchWithDevTools];
}
// Usage
function TodoApp() {
const [state, dispatch] = useReducerWithDevTools(
todoReducer,
{ todos: [], filter: 'all' },
'TodoApp'
);
// Now your useReducer state appears in Redux DevTools!
// You can time-travel, see action history, export state, etc.
return (
<div>
<button onClick={() => dispatch({ type: 'ADD_TODO', text: 'Learn DevTools' })}>
Add Todo
</button>
{/* ... */}
</div>
);
}
Note: Redux DevTools can integrate with any state management solution, not just Redux. Use
the
__REDUX_DEVTOOLS_EXTENSION__ API to connect custom state managers.
18.3 State Logging and Debug Patterns
Implement structured logging patterns to track state changes and debug issues in development and production.
Warning: Never log sensitive data (passwords, tokens, PII) in production. Always sanitize
state before logging and disable verbose logging in production builds.
18.4 Time-travel Debugging for State Changes
Implement time-travel debugging to replay state changes and jump to any previous application state.
// Using Redux DevTools for automatic time-travel
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './rootReducer';
export const store = configureStore({
reducer: rootReducer,
devTools: {
name: 'My App',
features: {
jump: true, // Enable jumping to any action
skip: true, // Skip (ignore) actions
reorder: true, // Reorder actions
pause: true // Pause action recording
}
}
});
// In your component, Redux DevTools provides:
// 1. Timeline slider to scrub through states
// 2. Jump to any previous action
// 3. Skip/ignore specific actions
// 4. Replay action sequences
// You can also access DevTools programmatically:
function DebugPanel() {
const exportState = () => {
const state = store.getState();
const blob = new Blob([JSON.stringify(state, null, 2)], {
type: 'application/json'
});
// Download state snapshot
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `app-state-${Date.now()}.json`;
a.click();
};
const importState = (stateJson) => {
const state = JSON.parse(stateJson);
// Restore state through DevTools
window.__REDUX_DEVTOOLS_EXTENSION__?.send(
{ type: '@@IMPORT_STATE' },
state
);
};
return (
<div>
<button onClick={exportState}>Export Current State</button>
<input
type="file"
accept=".json"
onChange={(e) => {
const file = e.target.files[0];
file.text().then(importState);
}}
/>
</div>
);
}
Note: Time-travel debugging works best with immutable state updates. Ensure all state changes
create new objects rather than mutating existing ones for accurate history tracking.
18.5 Performance Profiling for State Updates
Profile and measure state update performance to identify bottlenecks and optimize re-renders.
Profiling Tool
Description
Use Case
React Profiler
Record component render times
Identify slow components
Performance API
Measure precise timing with marks
Custom performance tracking
Why Did You Render
Library to detect unnecessary renders
Find optimization opportunities
Chrome DevTools
Timeline recording and flame graphs
Analyze render performance
Example: React Profiler Component for State Updates
// ProfiledComponent.jsx
import { Profiler } from 'react';
function onRenderCallback(
id, // The "id" prop of the Profiler tree
phase, // "mount" or "update"
actualDuration, // Time spent rendering
baseDuration, // Estimated time without memoization
startTime, // When React began rendering
commitTime, // When React committed the update
interactions // Set of interactions for this update
) {
console.log({
id,
phase,
actualDuration,
baseDuration,
renderTime: commitTime - startTime,
interactions
});
// Send to analytics in production
if (actualDuration > 16) { // Slower than 60fps
console.warn(`⚠️ Slow render in ${id}: ${actualDuration}ms`);
// Send to monitoring service
sendToAnalytics({
type: 'slow_render',
component: id,
duration: actualDuration,
phase
});
}
}
export function ProfiledApp() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<App />
</Profiler>
);
}
// Profile specific components with state
function TodoList() {
const [todos, setTodos] = useState([]);
return (
<Profiler id="TodoList" onRender={onRenderCallback}>
<div>
{todos.map(todo => (
<Profiler key={todo.id} id={`Todo-${todo.id}`} onRender={onRenderCallback}>
<TodoItem todo={todo} />
</Profiler>
))}
</div>
</Profiler>
);
}
Note: Use React DevTools Profiler tab to record and analyze render performance visually. Look
for components with long render times or frequent unnecessary re-renders.
18.6 Custom Debug Hooks for State Monitoring
Create reusable debug hooks to monitor state changes, track renders, and identify performance issues.
// Production-safe debug hooks that only run in development
const isDevelopment = process.env.NODE_ENV === 'development';
export const useDebugHooks = {
useTraceUpdate: isDevelopment
? useTraceUpdate
: () => {},
useRenderCount: isDevelopment
? useRenderCount
: () => 0,
useDebugState: isDevelopment
? useDebugState
: useState,
useEffectDebugger: isDevelopment
? useEffectDebugger
: useEffect,
useComponentDidMount: isDevelopment
? useComponentDidMount
: () => {}
};
// Usage - automatically disabled in production
import { useDebugHooks } from './debugHooks';
function MyComponent(props) {
useDebugHooks.useTraceUpdate(props, 'MyComponent');
const renderCount = useDebugHooks.useRenderCount('MyComponent');
const [state, setState] = useDebugHooks.useDebugState(0, 'Counter');
// These hooks do nothing in production builds
return <div>Count: {state}</div>;
}
Note: Use useDebugValue in custom hooks to display formatted debug information
in
React DevTools. This helps identify hook values without console logging.
Section 18 Key Takeaways
React DevTools - Inspect component state, edit values in real-time, use
Profiler to identify performance issues
Redux DevTools - Time-travel debugging, action history, state diff
visualization, export/import state
State logging - Implement structured logging, sanitize sensitive data,
disable verbose logging in production
Time-travel - Store state history, implement undo/redo, jump to any
previous state for debugging
Performance profiling - Use React Profiler, track render times, identify
slow actions with middleware
Debug hooks - Create reusable hooks for tracing updates, counting renders,
logging state changes
19. State Security and Best Practices
19.1 Sensitive Data Handling in State
Implement secure patterns for handling sensitive data in application state to prevent exposure and leaks.
Security Practice
Description
Use Case
Minimize state storage
Don't store sensitive data in state unnecessarily
Passwords, credit cards, SSN
Memory cleanup
Clear sensitive data on unmount
Authentication tokens, PII
Avoid localStorage
Don't persist sensitive data in browser storage
Passwords, personal information
Use secure contexts
Isolate sensitive state in protected contexts
Payment info, user credentials
Redact in DevTools
Prevent sensitive data in dev tools
Development debugging
Example: Secure Sensitive Data Handling
// ❌ BAD: Storing sensitive data in state
function BadPaymentForm() {
const [creditCard, setCreditCard] = useState({
number: '',
cvv: '',
expiryDate: ''
});
// This data is visible in React DevTools and can be logged
return (
<form>
<input
value={creditCard.number}
onChange={(e) => setCreditCard({ ...creditCard, number: e.target.value })}
/>
</form>
);
}
// ✅ GOOD: Using uncontrolled inputs for sensitive data
function GoodPaymentForm() {
const cardNumberRef = useRef(null);
const cvvRef = useRef(null);
const expiryRef = useRef(null);
const handleSubmit = async (e) => {
e.preventDefault();
// Only access values when needed
const sensitiveData = {
cardNumber: cardNumberRef.current.value,
cvv: cvvRef.current.value,
expiry: expiryRef.current.value
};
// Send immediately without storing in state
await processPayment(sensitiveData);
// Clear immediately after use
cardNumberRef.current.value = '';
cvvRef.current.value = '';
expiryRef.current.value = '';
};
return (
<form onSubmit={handleSubmit}>
<input
ref={cardNumberRef}
type="text"
autoComplete="cc-number"
placeholder="Card Number"
/>
<input
ref={cvvRef}
type="text"
autoComplete="cc-csc"
placeholder="CVV"
/>
<input
ref={expiryRef}
type="text"
autoComplete="cc-exp"
placeholder="MM/YY"
/>
<button type="submit">Submit</button>
</form>
);
}
Example: Secure Context for Sensitive State
// SecureStateContext.jsx
import { createContext, useContext, useState, useEffect, useRef } from 'react';
const SecureStateContext = createContext(null);
export function SecureStateProvider({ children }) {
// Use ref to avoid DevTools exposure
const secureDataRef = useRef(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Store sensitive data in closure, not state
const setSensitiveData = (data) => {
secureDataRef.current = data;
};
const getSensitiveData = () => {
return secureDataRef.current;
};
const clearSensitiveData = () => {
if (secureDataRef.current) {
// Overwrite with zeros before clearing
if (typeof secureDataRef.current === 'object') {
Object.keys(secureDataRef.current).forEach(key => {
secureDataRef.current[key] = null;
});
}
secureDataRef.current = null;
}
};
// Clear on unmount
useEffect(() => {
return () => {
clearSensitiveData();
};
}, []);
// Clear on page visibility change (tab switch)
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
// Optionally clear when tab is hidden
// clearSensitiveData();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
const value = {
isAuthenticated,
setIsAuthenticated,
setSensitiveData,
getSensitiveData,
clearSensitiveData
};
return (
<SecureStateContext.Provider value={value}>
{children}
</SecureStateContext.Provider>
);
}
export function useSecureState() {
const context = useContext(SecureStateContext);
if (!context) {
throw new Error('useSecureState must be used within SecureStateProvider');
}
return context;
}
// Usage
function LoginForm() {
const { setSensitiveData, setIsAuthenticated } = useSecureState();
const passwordRef = useRef(null);
const handleLogin = async (e) => {
e.preventDefault();
const password = passwordRef.current.value;
// Don't store password in state!
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ password })
});
if (response.ok) {
const { token } = await response.json();
setSensitiveData({ token }); // Store in ref, not state
setIsAuthenticated(true);
passwordRef.current.value = ''; // Clear immediately
}
};
return (
<form onSubmit={handleLogin}>
<input ref={passwordRef} type="password" />
<button>Login</button>
</form>
);
}
Warning: Never store passwords, credit card numbers, or other highly sensitive data in React
state. Use uncontrolled inputs with refs, and transmit data directly to the server without storing it.
19.2 State Sanitization and Validation
Validate and sanitize all state data to prevent malicious input and ensure data integrity.
Warning: Never trust user input. Always sanitize HTML content before rendering with
dangerouslySetInnerHTML. Use DOMPurify or similar libraries to remove malicious code.
19.4 State Encryption for Client-side Storage
Encrypt sensitive state data before storing in localStorage, sessionStorage, or IndexedDB.
Encryption Technique
Description
Use Case
Web Crypto API
Browser-native encryption
Secure client-side encryption
AES-GCM
Authenticated encryption algorithm
Strong encryption standard
Key derivation
Derive keys from user passwords
Password-based encryption
Secure key storage
Never store keys in localStorage
Memory or session only
Example: Encrypted State Storage Hook
// encryption.ts
class StateEncryption {
private key: CryptoKey | null = null;
async generateKey(password: string): Promise<void> {
const encoder = new TextEncoder();
const passwordBuffer = encoder.encode(password);
// Derive key from password using PBKDF2
const keyMaterial = await crypto.subtle.importKey(
'raw',
passwordBuffer,
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
this.key = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode('unique-app-salt'), // Use unique salt per app
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
async encrypt(data: any): Promise<string> {
if (!this.key) throw new Error('Key not initialized');
const encoder = new TextEncoder();
const plaintext = encoder.encode(JSON.stringify(data));
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
this.key,
plaintext
);
// Combine IV and ciphertext
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), iv.length);
// Convert to base64
return btoa(String.fromCharCode(...combined));
}
async decrypt(encryptedData: string): Promise<any> {
if (!this.key) throw new Error('Key not initialized');
// Decode base64
const combined = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0));
// Extract IV and ciphertext
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
this.key,
ciphertext
);
const decoder = new TextDecoder();
return JSON.parse(decoder.decode(plaintext));
}
clearKey(): void {
this.key = null;
}
}
export const stateEncryption = new StateEncryption();
// useEncryptedStorage.ts
import { useState, useEffect, useCallback } from 'react';
import { stateEncryption } from './encryption';
export function useEncryptedStorage<T>(
key: string,
initialValue: T
): [T, (value: T) => Promise<void>, () => void] {
const [state, setState] = useState<T>(initialValue);
// Load encrypted data on mount
useEffect(() => {
const loadEncryptedData = async () => {
try {
const encrypted = localStorage.getItem(key);
if (encrypted) {
const decrypted = await stateEncryption.decrypt(encrypted);
setState(decrypted);
}
} catch (error) {
console.error('Failed to decrypt data:', error);
localStorage.removeItem(key);
}
};
loadEncryptedData();
}, [key]);
// Save encrypted data
const setEncryptedState = useCallback(async (value: T) => {
setState(value);
try {
const encrypted = await stateEncryption.encrypt(value);
localStorage.setItem(key, encrypted);
} catch (error) {
console.error('Failed to encrypt data:', error);
}
}, [key]);
// Clear encrypted data
const clearEncryptedState = useCallback(() => {
setState(initialValue);
localStorage.removeItem(key);
}, [key, initialValue]);
return [state, setEncryptedState, clearEncryptedState];
}
// Usage
function UserPreferences() {
const [preferences, setPreferences, clearPreferences] = useEncryptedStorage(
'user_preferences',
{ theme: 'light', notifications: true }
);
useEffect(() => {
// Initialize encryption key from user password
// (In real app, get this after user login)
stateEncryption.generateKey('user-password-or-session-token');
return () => {
// Clear key on unmount
stateEncryption.clearKey();
};
}, []);
const handleSave = async () => {
await setPreferences({
...preferences,
theme: 'dark'
});
};
return (
<div>
<p>Theme: {preferences.theme}</p>
<button onClick={handleSave}>Save Encrypted</button>
<button onClick={clearPreferences}>Clear</button>
</div>
);
}
Note: Client-side encryption provides defense-in-depth but is not a substitute for
server-side
security. The encryption key must be protected and should not be stored in localStorage.
19.5 Authentication State and Token Management
Securely manage authentication tokens and user session state in React applications.
Warning: Never store authentication tokens in localStorage or sessionStorage as they are
vulnerable to XSS attacks. Use HTTP-only cookies or in-memory storage (with automatic refresh).
19.6 State Audit Logging and Compliance
Implement audit logging for state changes to meet compliance requirements and track user actions.
Audit Pattern
Description
Use Case
Action logging
Record all state-changing actions
Compliance, debugging
User tracking
Associate changes with user IDs
Accountability, forensics
Timestamp tracking
Record when changes occurred
Timeline reconstruction
Data retention
Store audit logs securely
GDPR, HIPAA compliance
Immutable logs
Prevent log tampering
Legal requirements
Example: Audit Logging Middleware for Redux
// auditMiddleware.ts
import { Middleware } from '@reduxjs/toolkit';
interface AuditLog {
id: string;
userId: string | null;
action: string;
payload: any;
timestamp: string;
previousState: any;
nextState: any;
ipAddress?: string;
userAgent?: string;
}
const auditMiddleware: Middleware = (store) => (next) => async (action) => {
const previousState = store.getState();
const timestamp = new Date().toISOString();
// Execute action
const result = next(action);
const nextState = store.getState();
// Create audit log entry
const auditLog: AuditLog = {
id: crypto.randomUUID(),
userId: previousState.auth?.user?.id || null,
action: action.type,
payload: sanitizePayload(action.payload),
timestamp,
previousState: sanitizeState(previousState),
nextState: sanitizeState(nextState),
ipAddress: await getClientIP(),
userAgent: navigator.userAgent
};
// Send to audit logging service (async, non-blocking)
sendAuditLog(auditLog).catch(error => {
console.error('Failed to send audit log:', error);
});
// Store in local buffer for offline scenarios
storeLocalAuditLog(auditLog);
return result;
};
// Sanitize sensitive data before logging
function sanitizePayload(payload: any): any {
if (!payload) return payload;
const sanitized = { ...payload };
const sensitiveKeys = ['password', 'token', 'creditCard', 'ssn', 'cvv'];
Object.keys(sanitized).forEach(key => {
if (sensitiveKeys.some(sensitive => key.toLowerCase().includes(sensitive))) {
sanitized[key] = '[REDACTED]';
}
});
return sanitized;
}
function sanitizeState(state: any): any {
// Only log relevant parts of state, redact sensitive info
return {
user: state.auth?.user ? { id: state.auth.user.id } : null,
route: state.router?.location?.pathname,
// Add other non-sensitive state slices
};
}
async function sendAuditLog(log: AuditLog): Promise<void> {
await fetch('/api/audit-logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(log)
});
}
function storeLocalAuditLog(log: AuditLog): void {
const logs = JSON.parse(localStorage.getItem('audit_logs_buffer') || '[]');
logs.push(log);
// Keep only last 100 logs locally
if (logs.length > 100) {
logs.shift();
}
localStorage.setItem('audit_logs_buffer', JSON.stringify(logs));
}
async function getClientIP(): Promise<string | undefined> {
try {
const response = await fetch('https://api.ipify.org?format=json');
const data = await response.json();
return data.ip;
} catch {
return undefined;
}
}
export default auditMiddleware;
Note: Audit logs must comply with data protection regulations (GDPR, HIPAA, SOC 2). Implement
proper retention policies, encrypt logs at rest, and provide data export/deletion capabilities.
Section 19 Key Takeaways
Sensitive data - Don't store passwords/credit cards in state, use refs for
immediate transmission, clear on unmount
Validation - Validate all user input before storing, use Zod/Yup for schema
validation, sanitize HTML content
XSS prevention - React auto-escapes text, sanitize before
dangerouslySetInnerHTML, validate URLs, use CSP headers
Encryption - Use Web Crypto API for client-side encryption, never store
keys
in localStorage, AES-GCM for authenticated encryption
Auth tokens - Use HTTP-only cookies or memory storage, implement automatic
token refresh, auto-logout on inactivity
Audit logging - Log state changes with user ID and timestamp, sanitize
sensitive data, comply with GDPR retention policies
20. State Architecture and Design Patterns
20.1 Flux Architecture Patterns in React
Flux is a unidirectional data flow pattern that provides predictable state management through actions,
dispatcher, stores, and views.
Note: Modern Redux is based on Flux principles but simplifies the pattern by using a single
store and removing the dispatcher. Redux Toolkit further simplifies Redux implementation.
20.2 Model-View-Update (MVU) Patterns
MVU (also known as The Elm Architecture) separates state (Model), rendering (View), and state transitions
(Update) into distinct functions.
Note: MVU pattern ensures predictable state management by making all state transitions explicit
and testable. Update functions are pure, making them easy to test and reason about.
20.3 Unidirectional Data Flow Implementation
Implement strict unidirectional data flow to ensure predictable state updates and easier debugging.
Flow Step
Description
Data Direction
1. User Action
User interacts with UI
UI → Action
2. Dispatch Action
Action dispatched to store
Action → Store
3. Update State
Store updates state immutably
Store → New State
4. Notify Components
Components receive new state
State → Components
5. Re-render
Components re-render with new state
Components → UI
Example: Enforcing Unidirectional Flow with Custom Hook
Note: Unidirectional data flow makes state changes predictable and traceable. Use tools like
Redux DevTools to visualize the flow and debug state transitions.
20.4 State Composition and Module Boundaries
Structure state into composable modules with clear boundaries to improve maintainability and scalability.
Note: Define clear boundaries between modules. Each module should own its state and expose only
necessary selectors and actions. Avoid tight coupling between modules.
20.5 State Scaling Strategies for Large Applications
Implement strategies to scale state management as applications grow in size and complexity.
Scaling Strategy
Description
Benefit
Code splitting
Load state modules on demand
Reduce initial bundle size
Lazy reducers
Dynamically inject reducers
Load state logic when needed
State normalization
Flat, normalized data structures
Efficient updates, no duplication
Memoized selectors
Cache derived state calculations
Prevent unnecessary re-renders
State pagination
Load data in chunks
Handle large datasets efficiently
Virtual state
Windowing for large lists
Render only visible items
Example: Dynamic Reducer Injection
// store.ts - Store with dynamic reducer injection
import { configureStore, combineReducers } from '@reduxjs/toolkit';
const staticReducers = {
auth: authReducer,
ui: uiReducer
};
export function createStore() {
const asyncReducers = {};
const store = configureStore({
reducer: createReducer(asyncReducers)
});
// Add method to inject reducers
store.injectReducer = (key, reducer) => {
if (asyncReducers[key]) return; // Already injected
asyncReducers[key] = reducer;
store.replaceReducer(createReducer(asyncReducers));
};
return store;
}
function createReducer(asyncReducers) {
return combineReducers({
...staticReducers,
...asyncReducers
});
}
export const store = createStore();
// Feature module - Lazy loaded
// src/features/analytics/index.tsx
import { lazy, useEffect } from 'react';
import { analyticsReducer } from './analyticsSlice';
import { store } from '../../store';
// Inject reducer when module loads
export function Analytics() {
useEffect(() => {
store.injectReducer('analytics', analyticsReducer);
}, []);
return <AnalyticsDashboard />;
}
// Route configuration with code splitting
const Analytics = lazy(() => import('./features/analytics'));
function App() {
return (
<Router>
<Routes>
<Route path="/analytics" element={
<Suspense fallback={<Loading />}>
<Analytics />
</Suspense>
} />
</Routes>
</Router>
);
}
// Virtual scrolling with react-window
import { FixedSizeList } from 'react-window';
import { useSelector } from 'react-redux';
function LargeList() {
// Only store IDs in state, not full objects
const itemIds = useSelector(state => state.items.allIds); // 10,000+ items
const itemsById = useSelector(state => state.items.byId);
// Render only visible rows
const Row = ({ index, style }) => {
const itemId = itemIds[index];
const item = itemsById[itemId];
return (
<div style={style}>
{item.name}
</div>
);
};
return (
<FixedSizeList
height={600}
itemCount={itemIds.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}
Note: For large applications, combine multiple scaling strategies: normalize data, use memoized
selectors, implement code splitting, and virtualize large lists for optimal performance.
20.6 Micro-frontend State Management Strategies
Manage state across independent micro-frontends while maintaining isolation and enabling communication.
Strategy
Description
Use Case
Isolated state
Each micro-app owns its state
Independent features
Shared state bus
Event bus for cross-app communication
Loosely coupled apps
Global state shell
Host app manages shared state
Auth, theme, navigation
Module federation
Share state libraries via webpack
Consistent state management
Custom events
Browser custom events for messaging
Framework-agnostic communication
Example: Shared State Bus for Micro-frontends
// stateBus.ts - Central event bus for micro-frontends
class StateBus {
private listeners = new Map<string, Set<Function>>();
// Subscribe to state changes
subscribe(event: string, callback: Function) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
return () => {
this.listeners.get(event)?.delete(callback);
};
}
// Publish state changes
publish(event: string, data: any) {
console.log(`📢 Event published: ${event}`, data);
this.listeners.get(event)?.forEach(callback => callback(data));
}
// Get current value (optional persistence)
private storage = new Map<string, any>();
getState(key: string) {
return this.storage.get(key);
}
setState(key: string, value: any) {
this.storage.set(key, value);
this.publish(key, value);
}
}
// Global singleton
export const stateBus = new StateBus();
// Micro-frontend A: Auth app
// apps/auth/src/AuthApp.tsx
import { stateBus } from '@shared/state-bus';
export function AuthApp() {
const [user, setUser] = useState(null);
const handleLogin = async (credentials) => {
const userData = await login(credentials);
setUser(userData);
// Publish auth state to other micro-apps
stateBus.setState('auth.user', userData);
stateBus.publish('auth.login', { user: userData });
};
const handleLogout = () => {
setUser(null);
stateBus.setState('auth.user', null);
stateBus.publish('auth.logout', {});
};
return <LoginForm onLogin={handleLogin} />;
}
// Micro-frontend B: Dashboard app
// apps/dashboard/src/DashboardApp.tsx
import { stateBus } from '@shared/state-bus';
export function DashboardApp() {
const [user, setUser] = useState(stateBus.getState('auth.user'));
useEffect(() => {
// Subscribe to auth events from other micro-apps
const unsubscribeLogin = stateBus.subscribe('auth.login', (data) => {
console.log('Dashboard: User logged in', data.user);
setUser(data.user);
});
const unsubscribeLogout = stateBus.subscribe('auth.logout', () => {
console.log('Dashboard: User logged out');
setUser(null);
});
return () => {
unsubscribeLogin();
unsubscribeLogout();
};
}, []);
if (!user) return <div>Please log in</div>;
return <div>Welcome, {user.name}</div>;
}
// Micro-frontend C: Shopping cart app
// apps/cart/src/CartApp.tsx
export function CartApp() {
const [user, setUser] = useState(null);
const [items, setItems] = useState([]);
useEffect(() => {
// Listen for auth changes
const unsubscribe = stateBus.subscribe('auth.user', (userData) => {
setUser(userData);
if (userData) {
// Load cart for logged-in user
loadCart(userData.id).then(setItems);
} else {
// Clear cart on logout
setItems([]);
}
});
return unsubscribe;
}, []);
const handleAddItem = (item) => {
setItems(prev => [...prev, item]);
// Publish cart update
stateBus.publish('cart.updated', { items: [...items, item] });
};
return <CartList items={items} onAddItem={handleAddItem} />;
}
Example: Custom Events for Framework-Agnostic Communication
// webpack.config.js - Host app
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
auth: 'auth@http://localhost:3001/remoteEntry.js',
dashboard: 'dashboard@http://localhost:3002/remoteEntry.js'
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
'@reduxjs/toolkit': { singleton: true },
'react-redux': { singleton: true }
}
})
]
};
// Host app store - shared across micro-apps
// host/src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
export const store = configureStore({
reducer: {
shared: sharedReducer, // Shared global state
// Micro-apps will inject their reducers dynamically
}
});
// Expose store to micro-apps
window.__SHARED_STORE__ = store;
// Remote micro-app
// auth/src/bootstrap.tsx
import { Provider } from 'react-redux';
function AuthMicroApp() {
// Use shared store from host
const store = window.__SHARED_STORE__;
// Inject auth reducer
useEffect(() => {
store.injectReducer('auth', authReducer);
}, []);
return (
<Provider store={store}>
<AuthApp />
</Provider>
);
}
Warning: Micro-frontend state sharing should be minimal. Prefer isolated state with
event-based communication to maintain independence and avoid tight coupling between apps.
Section 20 Key Takeaways
Flux architecture - Unidirectional flow with actions, dispatcher, stores,
and
views for predictable state management
MVU pattern - Separate Model, View, and Update functions; pure update
functions for testable state transitions
Unidirectional flow - Enforce one-way data flow with middleware, immutable
state updates, and action dispatching
State composition - Organize by domain/feature, use combineReducers,
maintain
clear module boundaries
Scaling strategies - Normalize data, use code splitting, lazy reducers,
memoized selectors, and virtual scrolling
Micro-frontends - Isolate state per app, use event bus for communication,
share libraries via module federation