Agent Interface I covered React’s mental model and component styling through design tokens. This article dives deeper into execution: why React is built on functional programming, how components render and re-render, where data lives and flows, and how to manage side effects. Three sections build from theory to practice: FP foundations explain React’s design decisions, data-driven mental models show where state lives, and component patterns demonstrate composition and lifecycle management.
Rather than showing step-by-step chat interface implementation, this article teaches the foundational patterns you need to build any complex interface. A prompt bank (coming soon) will provide guided implementations—chat interfaces, streaming responses, tool visualization—that reference these fundamentals, letting you apply the patterns to your specific use cases.
Functional Programming in React
React isn’t just a UI library—it’s built on functional programming principles that shape how you think about building interfaces[1]. Understanding these principles explains why React works the way it does: why components are functions, why you can’t mutate state, why useEffect exists.
Functional programming concepts like pure functions, immutability, and controlled side effects aren’t academic theory—they’re the foundation of React’s design. The following tabs connect FP theory to React implementation, showing how these principles enable React’s declarative model and automatic re-rendering. Master these concepts and React’s “magic” becomes predictable, debuggable engineering.
Imperative programming tells the computer how to do something step-by-step: find this element, change its text, add this class. Declarative programming describes what you want: when isCompleted is true, show “Done”; when false, show “Pending”. React is declarative—you declare the desired UI state, and React figures out the DOM manipulation.
The Mental Model Shift
React represents a fundamental shift in how you build UIs. Instead of telling the browser how to update the DOM step-by-step, you describe what the UI should look like.
Key Concepts
Step-by-step instructions: Find element, update text, toggle class
Describe the result: UI = f(state)
React handles the DOM updates automatically
UI = f(state) User interface is a function of state. Change state, UI updates automatically.
Interactive Demo: Todo Toggle
Try toggling the todo item. Notice how React automatically updates multiple parts of the UI.
Current state:
Code Comparison
// Imperative: Step-by-step DOM manipulation
const checkbox = document.querySelector('.todo-checkbox');
const text = document.querySelector('.todo-text');
const badge = document.querySelector('.todo-badge');
const item = document.querySelector('.todo-item');
checkbox.addEventListener('change', (e) => {
if (e.target.checked) {
// Manual updates for each element
text.textContent = 'Task completed!';
badge.textContent = 'Done';
item.classList.add('completed');
} else {
text.textContent = 'Complete this task';
badge.textContent = 'Pending';
item.classList.remove('completed');
}
}); // Declarative: Describe what UI should look like
function TodoItem() {
const [isCompleted, setIsCompleted] = useState(false);
return (
<div className={isCompleted ? 'completed' : ''}>
<input
type="checkbox"
checked={isCompleted}
onChange={(e) => setIsCompleted(e.target.checked)}
/>
<span>{isCompleted ? 'Task completed!' : 'Complete this task'}</span>
<span>{isCompleted ? 'Done' : 'Pending'}</span>
</div>
);
} Why This Matters
Same state always produces same UI. No hidden DOM state.
One state update instead of multiple DOM manipulations.
Impossible to forget updating UI - React handles it.
A pure function’s output depends only on its inputs—no hidden dependencies, no external state, no mutations. Call it twice with the same arguments, you get identical results. React components are pure functions: given the same props and state, they must render the same UI. This purity is what enables React to skip re-renders, cache results, and make rendering predictable.
Pure Functions
In functional programming, a pure function is predictable: given the same inputs, it always returns the same output and has no side effects.
Pure Function Rules
Deterministic behavior, no randomness or external dependencies
Doesn't modify variables outside its scope
Output depends only on input parameters
Components are pure functions. Given the same props, they must return the same JSX. This lets React optimize rendering and enables features like React.memo.
Pure vs Impure Functions
Understanding purity is fundamental to React's design
// Pure: Same inputs → Same output
function add(a, b) {
return a + b;
}
add(2, 3); // Always returns 5
add(2, 3); // Always returns 5
add(2, 3); // Always returns 5 // Impure: Depends on external state
let count = 0;
function increment() {
count++; // Mutates external variable!
return count;
}
increment(); // Returns 1
increment(); // Returns 2
increment(); // Returns 3 - different each time! React Components Are Pure Functions
// Pure component: props in → JSX out
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
// Given same prop, always returns same JSX
<Greeting name="Alice" /> // Always: <h1>Hello, Alice!</h1>
<Greeting name="Alice" /> // Always: <h1>Hello, Alice!</h1>
// React can safely call this function multiple times
// Enables React.memo, useMemo optimizations // Impure component: depends on external state
let renderCount = 0;
function BadCounter() {
renderCount++; // Side effect! Modifies external variable
return <div>Rendered {renderCount} times</div>;
}
// Same props, different output each time
<BadCounter /> // First render: "Rendered 1 times"
<BadCounter /> // Second render: "Rendered 2 times"
// React can't optimize this - output is unpredictable Why Purity Matters in React
React can call your component multiple times during rendering. Purity guarantees consistent results.
Test components like functions: provide props, verify JSX output. No setup or mocking needed.
React.memo and useMemo work because components are pure - same props = same output.
React detects changes by comparing object references, not inspecting contents. Mutating an array or object keeps the same memory reference—React sees no change, skips re-rendering. Creating a new array or object (with spread syntax or array methods like .map(), .filter()) produces a new reference—React detects the change and re-renders. Immutability isn’t dogma; it’s how React’s change detection works.
Immutability
In functional programming, you don't mutate data—you create new versions. React relies on this principle for efficient change detection.
Why React Needs Immutability
React uses === to check if state changed
Modifying an object doesn't change its memory address
Creating new objects triggers re-renders
- Spread for arrays - Add to array
array.filter()- Remove from arrayarray.map()- Update array items- Spread for objects - Update object
Interactive Demo: Mutation vs Immutability
Try adding todos using both approaches below
Using array.push() - mutates existing array, same reference, no re-render
Using spread syntax - creates new array, new reference, triggers re-render
Mutation vs Immutable Updates
// WRONG: Mutation doesn't trigger re-render
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
todos.push(text); // Mutation! Same reference
setTodos(todos); // React sees same reference = no re-render
};
return (
<div>
{todos.map(todo => <div>{todo}</div>)}
</div>
);
} // CORRECT: New array triggers re-render
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
setTodos([...todos, text]); // New array! New reference
};
return (
<div>
{todos.map(todo => <div>{todo}</div>)}
</div>
);
} Common Immutable Update Patterns
Spread syntax to create new array array.filter(item => item.id !== id) array.map() with spread in callback Spread syntax to merge properties A side effect is any operation that reaches outside the function’s scope—outside its parameters, local variables, and return value. Reading from or writing to the network, DOM, browser storage, timers, or console are all side effects. Pure components can’t contain side effects, but applications need them. useEffect is React’s controlled boundary: it runs side effects after rendering completes, preventing infinite loops and maintaining component purity.
Side Effects
A side effect is any interaction with the outside world. Pure functions can't have side effects, but applications need them. useEffect is React's escape hatch.
Common Side Effects
Network requests to APIs
WebSocket, setInterval, addEventListener
Direct access to document or window
Reading/writing localStorage
React components must be pure, but useEffect lets you run side effects AFTER rendering completes.
Why Side Effects in Render Break Things
// WRONG: Side effect in component body
function BadComponent() {
fetch('/api/data') // Runs on EVERY render!
.then(res => res.json())
.then(data => setData(data)); // Triggers re-render
// Which calls fetch again
// Infinite loop!
return <div>Data: {data}</div>;
} useEffect: Running Side Effects Safely
// CORRECT: Side effect in useEffect
function GoodComponent() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(setData);
}, []); // Empty deps = runs once on mount
return <div>Data: {data}</div>;
} Common useEffect Patterns
// Run once when component mounts
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(setUser);
}, []); // Empty array = run once Perfect for initial data fetching. Runs once when component appears, never again.
// Run when userId changes
useEffect(() => {
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]); // Runs when userId changes Effect runs on mount AND whenever userId changes. React compares dependencies before each render.
// Cleanup when component unmounts
useEffect(() => {
const timer = setInterval(() => {
console.log('tick');
}, 1000);
return () => clearInterval(timer); // Cleanup!
}, []); Return a cleanup function to stop timers, cancel subscriptions, or remove event listeners when component unmounts.
When useEffect Runs
React calls your component function, generates JSX
React commits changes to the DOM
useEffect callbacks execute AFTER paint
React’s Mental Model: Data-Driven UI
Web interactivity requires JavaScript. You can manipulate the DOM directly or declare state and let React sync the UI automatically. Understanding WHERE data lives and HOW it flows is foundational. The following tabs build this mental model: event-driven vs data-driven approaches, data source architecture, and the four places state can live (URL, component, global, persistent storage).
Event-driven code finds elements and updates them manually. Data-driven code declares state and references it in UI—when state changes, React re-renders automatically. One piece of state can control multiple UI elements simultaneously. The challenge with event-driven approaches: keeping everything in sync as complexity grows.
Event-Driven vs Data-Centric
Both approaches can build interactivity. But complexity scales differently.
Click the checkbox to toggle the todo item. Watch how one interaction affects multiple UI elements simultaneously.
Understanding the core concepts: events trigger state changes, and state determines UI. One piece of data can control many visual elements.
Compare how Vanilla JavaScript requires manually updating each UI element, while React automatically propagates state changes to all dependent elements.
As features multiply, Vanilla JavaScript code grows linearly. React stays manageable because UI derives from state automatically.
Click the checkbox and pay attention to the following:
- Checkbox
- Text Style
- Badge
- Background
What happened? One click triggered 4 UI changes:
- Checkbox state (checked/unchecked)
- Text decoration (strikethrough)
- Completed badge (show/hide)
- Background color (green tint)
Core Concepts
| Concept | Description | Example |
|---|---|---|
| Event | User interaction that triggers change | onClick → User clicks checkbox |
| State / Data | Variables that hold the current status | isCompleted = true / false |
| UI Updates | Visual elements that depend on state | One state → 4 UI elements (Checkbox, Text, Badge, Background) |
let isCompleted = false;
const checkbox = document.getElementById('checkbox');
const text = document.getElementById('text');
const badge = document.getElementById('badge');
const container = document.getElementById('container');
checkbox.addEventListener('click', () => {
isCompleted = !isCompleted;
// Manually update EACH element
checkbox.checked = isCompleted;
text.style.textDecoration =
isCompleted ? 'line-through' : 'none';
badge.style.display = isCompleted ? 'block' : 'none';
container.style.backgroundColor =
isCompleted ? '#f0fdf4' : 'white';
}); function TodoItem() {
const [isCompleted, setIsCompleted] = useState(false);
return (
<div className={isCompleted ? 'completed' : ''}>
<input
type="checkbox"
checked={isCompleted}
onClick={() => setIsCompleted(!isCompleted)}
/>
<span className={isCompleted ? 'strikethrough' : ''}>
Complete tutorial
</span>
{isCompleted && <Badge>✓ Completed</Badge>}
</div>
);
} The Difference: Vanilla JS finds and updates each element manually. React automatically re-renders when state changes—all UI updates happen automatically.
Lines of Code as Features Grow
Why This Matters:
Adding a priority badge means manually updating 2 more elements (badge visibility + icon color) in Vanilla JS. In React, you just reference the state: {priority && <PriorityBadge />}
Every new feature multiplies manual updates in Vanilla JS. React complexity stays flat because UI derives from state automatically.
React applications have five data locations: URL parameters (shareable), component state (temporary), global state (shared across components), browser storage (persistent), and server data (source of truth). Each location has different characteristics—scope, persistence, reactivity. Choosing where data lives is an architectural decision that affects shareability, performance, and complexity. The diagram below maps this landscape.
Where Data Lives
Click each location to learn more
Component State
Characteristics
| Persistence: | Lost when component unmounts |
| Scope: | Single component (and its children via props) |
| Speed: | Instant (in-memory) |
When to Use
- UI-only state (modal open/closed, dropdown expanded)
- Temporary form inputs before submission
- Component-specific toggles or selections
Example
import { useState } from 'react';
function TodoItem() {
const [isCompleted, setIsCompleted] = useState(false);
return (
<div>
<input
type="checkbox"
checked={isCompleted}
onChange={() => setIsCompleted(!isCompleted)}
/>
<span style={{
textDecoration: isCompleted ? 'line-through' : 'none'
}}>
Buy groceries
</span>
</div>
);
} Global State
Characteristics
| Persistence: | Survives component unmounts, lost on page refresh |
| Scope: | Global (accessible from anywhere) |
| Speed: | Instant (in-memory) |
When to Use
- Shared UI state (sidebar open/closed across pages)
- User session data (current user, auth status)
- Application-wide settings or preferences
Example (Zustand)
import { create } from 'zustand';
// Create global store
const useStore = create((set) => ({
sidebarOpen: true,
toggleSidebar: () => set((state) => ({
sidebarOpen: !state.sidebarOpen
})),
}));
// Use in any component
function Header() {
const { sidebarOpen, toggleSidebar } = useStore();
return (
<button onClick={toggleSidebar}>
{sidebarOpen ? 'Close' : 'Open'} Sidebar
</button>
);
} Browser Storage
Characteristics
| Persistence: | Permanent (until explicitly cleared) |
| Scope: | Per-origin (shared across tabs for same domain) |
| Speed: | Fast (synchronous disk access) |
When to Use
- User preferences (theme, language, layout)
- Draft content (auto-saved forms)
- Cached data for offline access
Example
import { useState, useEffect } from 'react';
function ThemeToggle() {
// Load from localStorage or default to 'light'
const [theme, setTheme] = useState(() =>
localStorage.getItem('theme') || 'light'
);
// Save to localStorage whenever theme changes
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
return (
<button onClick={() => setTheme(
theme === 'light' ? 'dark' : 'light'
)}>
Switch to {theme === 'light' ? 'dark' : 'light'} mode
</button>
);
} Server Data
Characteristics
| Persistence: | Permanent (database-backed) |
| Scope: | Global (shared across users/devices) |
| Speed: | Network-dependent (async, can be slow) |
When to Use
- User-generated content (posts, comments, profiles)
- Real-time collaborative data (chat messages)
- Authoritative source of truth (inventory, transactions)
Example
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch user data from server
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
} URL State
Characteristics
| Persistence: | Bookmarkable, shareable (survives navigation) |
| Scope: | Per-page (changes on navigation) |
| Speed: | Instant (no network required) |
When to Use
- Search filters and pagination state
- Active tab or modal state (shareable links)
- Any state that should survive page refresh and be shareable
Example
import { useState, useEffect } from 'react';
function SearchPage() {
const [searchTerm, setSearchTerm] = useState('');
// Read from URL on mount
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const query = params.get('q') || '';
setSearchTerm(query);
}, []);
// Update URL when search changes
const handleSearch = (term) => {
setSearchTerm(term);
const params = new URLSearchParams({ q: term });
window.history.pushState({}, '', `?${params}`);
};
return (
<input
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search..."
/>
);
} URL state lives in the browser’s address bar—shareable, bookmarkable, and persistent across refreshes. Copy a URL with filters and paste it elsewhere: the filters appear automatically. Perfect for search filters, pagination, sort order, and any state users might want to share.
URL State
Store application state in the URL for shareability and persistence.
Key Properties
Users can copy and share links
State is visible in address bar
Survives page refresh
Updates browser history
Change the filters below, copy the URL, and paste it in a new tab!
Interactive Demo
Try changing filters below, then copy the URL and paste it in a new tab. The filters persist!
Users can share search results, active tabs, or specific views via URL
Users can bookmark specific application states and return to them later
Back/forward buttons work naturally - users expect this for navigation
import { useState, useEffect } from 'react';
function SearchPage() {
const [searchTerm, setSearchTerm] = useState('');
const [category, setCategory] = useState('all');
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setSearchTerm(params.get('q') || '');
setCategory(params.get('category') || 'all');
}, []);
const updateFilters = (newSearch, newCategory) => {
setSearchTerm(newSearch);
setCategory(newCategory);
const params = new URLSearchParams({
q: newSearch,
category: newCategory
});
window.history.pushState({}, '', `?${params}`);
};
return (
<div>
<input
value={searchTerm}
onChange={(e) => updateFilters(e.target.value, category)}
/>
</div>
);
} Component state is temporary data in a component’s memory—perfect for UI-only interactions (modals, toggles), form inputs, or data scoped to one component. It disappears when the component unmounts or the page refreshes.
Component State
Component state is temporary data that lives in React component memory, managed by the useState hook.
Key Properties
Resets when component unmounts
Updates trigger automatic re-renders
Only this component and children can access it
Lost on page refresh
Interact with the demos below, then refresh the page. Watch all state reset to initial values!
Interactive Demo
Component state is temporary and reactive. Try the examples below!
Counter Example
Current state: count =
Toggle Example
Current state: isVisible =
Modal open/closed, dropdown expanded, form validation errors - state that doesn't need to persist
Form inputs before submission, search queries, filters that reset on navigation
State that only matters to this component and its children - no need to share globally
import { useState } from 'react';
function Counter() {
// Declare state variable with initial value
const [count, setCount] = useState(0);
const [isVisible, setIsVisible] = useState(true);
return (
<div>
{/* Counter Example */}
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count - 1)}>-</button>
<button onClick={() => setCount(0)}>Reset</button>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
{/* Toggle Example */}
<div>
<button onClick={() => setIsVisible(!isVisible)}>
{isVisible ? 'Hide' : 'Show'} Content
</button>
{isVisible && (
<div>This content visibility is controlled by state</div>
)}
</div>
</div>
);
}
// State is lost when component unmounts!
// For persistent state, use localStorage or global state Global state solves prop drilling—when multiple components need the same data. Zustand provides reactive global state: components subscribe to slices, and re-render only when those slices change. The demo below visualizes subscription patterns: subscribing to the entire store vs using selectors for specific values.
How Subscriptions Work
When a component uses a store, it "subscribes" (registers as a listener). But HOW you subscribe matters:
- Full store:
useStore()→ re-renders on ANY change - Selector:
useStore(s => s.count)→ re-renders ONLY when count changes
Try both buttons:
Watch which components re-render →
Store (In-Memory Object)
count: name: "" ComponentA
Full Storeconst store = useStore() return <div>{store.count}</div> Subscribes to: ALL properties (inefficient)
ComponentB
Selectorconst count = useStore(s => s.count) return <div>{count}</div> Subscribes to: count only (optimized)
Key takeaway:
Click "Update Name" to see the difference. ComponentA re-renders (subscribes to all), ComponentB stays stable (only subscribed to count).
State persistence combines localStorage (persistent but not reactive) with Zustand (reactive but not persistent). Hydration reads from localStorage asynchronously during page load—understanding this timing prevents race conditions. The timeline below visualizes the sequence step-by-step.
Hydration Lifecycle
When a page loads, hydration happens in a specific sequence. Understanding this timing prevents race conditions.
Why this sequence matters:
SSR sends HTML first for fast display → React makes it interactive (hydration) → Zustand's persistence adds timing complexity because localStorage is browser-only.
Server-Side Rendering (SSR)
Server runs React, generates static HTML markup. This HTML is sent to browser immediately for fast initial display.
Welcome
At this point: Browser shows HTML, but it's not interactive yet. No JavaScript has run.
Browser Fetches JavaScript Files
HTML contains <script src="/bundle.js"> tags. Browser makes HTTP requests to fetch these files, then parses and executes them.
Why separate? HTML arrives first (fast), JS loads in background (progressive enhancement). Users see content while waiting for interactivity.
React Hydration Process
React "hydrates" the static HTML by:
- Reconstructing the component tree in memory
- Attaching event listeners to existing DOM elements
- Setting up component state and effects
The page is now interactive! But...
⚠️ DANGER ZONE: Zustand hasn't read localStorage yet!
React has hydrated, but persistence middleware hasn't run. Store still has default values.
// ❌ Reading now gets default values!
const theme = useThemeStore.getState().theme
// Returns: "light" (default)
// localStorage has "dark" but not loaded yet! Problem: Reading from store at this point gets stale defaults, causing UI flash.
✓ Zustand reads from localStorage
Store updates with persisted state. Now safe to read!
// ✓ Solution: Check if hydrated first
if (useThemeStore.persist.hasHydrated()) {
const theme = useThemeStore.getState().theme
applyTheme(theme) // ← Gets "dark" (correct!)
} else {
// Wait for hydration to finish
useThemeStore.persist.onFinishHydration((state) => {
applyTheme(state.theme)
})
} Solution: Use hasHydrated() to check, or wait with onFinishHydration().
Components re-render with correct state
✓ UI matches localStorage
✓ Hydration complete
Safe to read from store
Building with Components
With FP foundations and mental models established, let’s explore how components work in practice. Components compose into larger structures, React manages their lifecycle automatically, and useEffect bridges the gap to external systems (APIs, WebSockets, timers, storage). The following tabs cover composition patterns, lifecycle phases, side effect management, and advanced state optimization.
Components compose into larger structures—smaller pieces combine to build complex UIs. Props flow down the tree, events bubble up. This composition model makes components reusable: a component doesn’t know where it’s used, it just receives props and emits events.
<ConversationSidebar>
ParentProps: collapsed: boolean
Role: Container for navigation and user profile
return <div><ConversationList /> <UserProfile /></div>
Parent Responsibilities:
- Handles
onSelect→ UpdatesactiveIdstate - Handles
onLogout→ Clears user session - Manages sidebar collapse state
- Passes state down to children as props
<ConversationList>
ChildProps: conversations[], activeId, onSelect
Role: Maps over conversations array
conversations.map(c => <ConversationItem key={c.id} ...props />)
↓ Props flow DOWN to children
<ConversationItem>
GrandchildProps: id, title, isActive, onClick
Role: Renders single conversation preview
↑ Click event → onClick → Parent updates activeId
<UserProfile>
ChildProps: user, onLogout, collapsed
Role: Display user info, theme toggle, settings navigation
↑ onLogout event → Parent handles logout
React automatically manages three phases: Mount (component appears), Update (state/props change), Unmount (component removed). Your component function runs on mount and every update. Effects run after rendering, cleanup runs on unmount. Understanding this timing prevents bugs with stale data and memory leaks.
Component Lifecycle
React manages component lifecycles automatically. Your component function is called when it mounts, re-called on every update, and can run cleanup when it unmounts.
The 3 Phases
Component appears in the DOM for the first time
Props or state change → React re-renders
Component is removed from the DOM
- State changes (useState setter)
- Props change from parent
- Parent component re-renders
Interactive Lifecycle Demo
Watch the timeline and console as you interact with the component
Component appears
State/props change
Component removed
Live Component
Render count:
Component has been unmounted. Cleanup functions would run here.
Lifecycle in Code
function Counter() {
const [count, setCount] = useState(0);
// This runs on EVERY render (mount + all updates)
console.log('Rendering with count:', count);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
// Mount: Rendering with count: 0
// Update: Rendering with count: 1
// Update: Rendering with count: 2 Your component function runs on mount and every update. Each render sees the current state value.
function Counter() {
const [count, setCount] = useState(0);
// Runs on mount only
useEffect(() => {
console.log('MOUNT: Component appeared');
}, []);
// Runs on mount AND every count change
useEffect(() => {
console.log('UPDATE: Count changed to', count);
}, [count]);
return <div>Count: {count}</div>;
} useEffect with empty deps runs on mount. useEffect with dependencies runs on mount and when dependencies change.
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
console.log('MOUNT: Starting timer');
const interval = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
// Cleanup runs on UNMOUNT
return () => {
console.log('UNMOUNT: Stopping timer');
clearInterval(interval);
};
}, []);
return <div>Seconds: {seconds}</div>;
} Return a cleanup function from useEffect. React calls it when the component unmounts, preventing memory leaks.
You learned WHY side effects exist in the Functional Programming section. This tab shows HOW to use useEffect correctly: run once on mount, run when dependencies change, cleanup on unmount. Dependencies matter—missing them creates stale closures, including them unnecessarily creates performance issues.
Side Effects & Cleanup
The Functional Programming section explained WHY side effects exist. This tab shows HOW to use useEffect correctly in real applications.
useEffect Quick Reference
useEffect(fn, []) Run once on mount
useEffect(fn, [dep]) Run when dep changes
useEffect(() => cleanup) Cleanup on unmount
- Fetching data from APIs
- WebSocket connections
- Event listeners
- Timers and intervals
- Scroll/focus management
Practical useEffect Patterns
Real-world examples showing when and how to use useEffect
Pattern: Fetch Data Once on Mount
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, []); // Empty array = runs once
if (loading) return <div>Loading...</div>;
return <div>Welcome, {user.name}!</div>;
} Pattern: Fetch When Data Changes
function UserPosts({ userId }) {
const [posts, setPosts] = useState([]);
useEffect(() => {
setPosts([]); // Clear old posts
fetch(`/api/posts?userId=${userId}`)
.then(res => res.json())
.then(setPosts);
}, [userId]); // Re-fetch when userId changes
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
} Pattern: Subscription with Cleanup
function LiveChat({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
// Subscribe to WebSocket
const ws = new WebSocket(`wss://chat.example.com/${roomId}`);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
setMessages(prev => [...prev, msg]);
};
// Cleanup: Close connection on unmount or roomId change
return () => {
ws.close();
};
}, [roomId]);
return <div>{messages.map(m => <p>{m.text}</p>)}</div>;
} Common Mistakes to Avoid
// WRONG: query not in dependency array
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}, []); // BUG: Effect never re-runs when query changes!
return <ul>{results.map(r => <li>{r}</li>)}</ul>;
}
// CORRECT: Include all dependencies
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}, [query]); // Runs when query changes // WRONG: Effect runs on every render
function BadComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // NO dependency array!
// Triggers re-render → effect runs → triggers re-render...
}); // INFINITE LOOP!
return <div>{count}</div>;
}
// CORRECT: Add dependency array
useEffect(() => {
// Run specific logic
}, []); // Or [dependency] When Do Effects Run?
Component function runs
React commits to DOM
User sees changes
useEffect callbacks execute
Effects run AFTER the browser paints. This prevents blocking the UI with slow operations like network requests.
Advanced subscription patterns handle complex data structures. Objects and arrays create new references on each selector call—even with identical contents. Zustand’s shallow comparison prevents unnecessary re-renders. Subscribe to derived values (length, filtered subsets) instead of entire arrays for better performance.
Shallow Equality for Objects/Arrays Advanced
When selectors return objects or arrays, they create NEW instances every render, causing unnecessary re-renders.
❌ Problem: New Object Each Time
const user = useStore(s => ({
name: s.name,
email: s.email
})) Re-renders EVERY time, even if name and email didn't change. The object { name, email } is a new reference each time.
✓ Solution: Use Shallow Equality
import { shallow } from 'zustand/shallow'
const user = useStore(
s => ({ name: s.name, email: s.email }),
shallow // ← Compare properties
) Only re-renders if name or email actually changed. Shallow equality compares object properties.
Optimizing Array Subscriptions Advanced
Don't subscribe to entire arrays if you only need derived values.
❌ Problem: Subscribe to Entire Array
const tasks = useStore(s => s.tasks)
return <div>Total: {tasks.length}</div> Re-renders when ANY task changes—even if task count stays the same!
✓ Solution: Subscribe to Derived Value
const taskCount = useStore(s => s.tasks.length)
return <div>Total: {taskCount}</div> Only re-renders when count changes. Task content changes don't trigger re-renders.
Other Array Patterns:
const first = useStore(s => s.tasks[0]) Subscribe to specific item const completed = useStore(s => s.tasks.filter(t => t.done)) Subscribe to filtered subset const ids = useStore(s => s.tasks.map(t => t.id), shallow) Subscribe to array of IDs (with shallow) Computed State in Selectors Advanced
You can compute values directly in selectors to avoid storing redundant data.
✓ Pattern: Compute in Selector
const completedCount = useStore(s =>
s.tasks.filter(t => t.completed).length
) Selector runs on each state change, returns computed value. Component only re-renders if computed value changes.
⚠️ Performance note:
Expensive computations in selectors run on every store update. For heavy computations, consider:
- Computing in the store action with
set() - Using
useMemo()in the component - Caching computed values in the store
Best Practices
- Use
shallowwhen selectors return objects or arrays - Subscribe to derived values (length, filtered results) instead of full arrays
- Keep selectors simple—expensive logic belongs in stores or memoized
- Always test subscription patterns with React DevTools Profiler
API Integration and Data Fetching
API integration is where everything converges. Pure functions transform server responses into UI-ready data. Immutability enables change detection for cache invalidation. Side effects (useEffect) control network I/O and polling. State management determines where fetched data lives—component state for local data, global state for shared data, localStorage for persistence. The lifecycle you learned governs when effects run and when cleanup happens.
The following tabs demonstrate real-world patterns, progressing from basic fetching to production architectures. Each pattern explicitly references concepts from previous sections, showing why each FP principle and React pattern matters in practice. This is the synthesis—functional programming principles, mental models, and component patterns all working together to build responsive, performant applications.
The foundational pattern: useEffect for the side effect (network request), useState for storing the result. The component function remains pure—given the same state, it renders the same UI.
When you need this: Loading user profiles, fetching dashboard data on page load, displaying product details, showing initial content—any time you need to fetch data once when a component appears.
Concepts Applied: Side effects (useEffect controls network I/O), Component state (useState), Lifecycle (fetch on mount), Pure rendering
import { useState, useEffect } from 'react'
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// Side effect: fetch runs AFTER initial render
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(data => {
setUser(data) // Immutable update
setLoading(false)
})
}, []) // Empty deps = run once on mount
// Pure rendering: same state = same UI
if (loading) return <p>Loading...</p>
if (!user) return <p>User not found</p>
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}Why this works: useEffect controls the side effect (network I/O happens AFTER render, preventing infinite loops). Empty dependency array [] means “run once on mount”—the lifecycle pattern from Section 3. The component remains pure—given the same state, it always renders the same UI.
The Aha Moment: “This is why we learned side effects in Section 1! Network I/O must happen in useEffect. This is why we learned lifecycle in Section 3! Empty deps
[]runs on mount. Components are pure functions of state.”
Declarative UI means describing all possible states—idle, loading, success, error. Describe what each state looks like, and React handles the transitions.
Concepts Applied: Declarative programming, State as single source of truth, Immutability (new state objects trigger re-renders)
import { useState, useEffect } from 'react'
type Status = 'idle' | 'loading' | 'success' | 'error'
function ProductList() {
const [status, setStatus] = useState<Status>('idle')
const [products, setProducts] = useState([])
const [error, setError] = useState(null)
useEffect(() => {
async function loadProducts() {
setStatus('loading')
try {
const response = await fetch('/api/products')
if (!response.ok) throw new Error('Failed to fetch')
const data = await response.json()
setProducts(data)
setStatus('success')
} catch (err) {
setError(err.message)
setStatus('error')
}
}
loadProducts()
}, [])
// Declarative rendering: describe each state
if (status === 'loading') return <p>Loading...</p>
if (status === 'error') return <p>Error: {error}</p>
return (
<ul>
{products.map(p => (
<li key={p.id}>{p.name} - ${p.price}</li>
))}
</ul>
)
}Why declarative states win: You describe what each state looks like. State is the single source of truth—check status === 'loading', not the DOM. Immutability enables transitions: setStatus('loading') creates a new value, React detects change, re-renders.
Alternative: Type-safe discriminated unions
type State =
| { status: 'loading' }
| { status: 'success'; data: Product[] }
| { status: 'error'; error: string }
const [state, setState] = useState<State>({ status: 'loading' })
switch (state.status) {
case 'loading': return <Skeleton />
case 'error': return <Error message={state.error} />
case 'success': return <List items={state.data} />
}The Aha Moment: “Declarative UI (Section 1) means describing states, not transitions. State is the source of truth (Section 2). Immutability (Section 1) makes React detect changes and re-render.”
Empty dependencies [] run once on mount. Specified dependencies make effects reactive—when props, state, or URL params change, effects re-run and refetch data.
When you need this: Search interfaces (refetch when query changes), filtered product lists (refetch when category/price changes), tabbed content (fetch data for active tab), pagination (fetch new page on click), master-detail views (fetch details when user selects item)—any time data needs to update based on user interaction or URL changes.
Concepts Applied: Effect dependencies (when to re-run), Cleanup functions (prevent race conditions), URL state integration, Stale closures
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
function SearchResults() {
const [searchParams] = useSearchParams()
const query = searchParams.get('q') || ''
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
const controller = new AbortController()
async function fetchResults() {
setLoading(true)
try {
const response = await fetch(
`/api/search?q=${query}`,
{ signal: controller.signal }
)
const data = await response.json()
setResults(data)
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Fetch failed:', error)
}
} finally {
setLoading(false)
}
}
fetchResults()
return () => controller.abort() // Cleanup
}, [query]) // Re-run when query changes
return <ul>{results.map(item => <li key={item.id}>{item.title}</li>)}</ul>
}Why dependencies matter: URL changes from ?q=react to ?q=vue → query changes → effect re-runs → fetch new results. Cleanup prevents race conditions: if user types “vue” before “react” fetch completes, cleanup cancels the “react” fetch.
Common patterns:
// Debounced fetching (avoid fetch on every keystroke)
const [searchTerm, setSearchTerm] = useState('')
const [debouncedTerm, setDebouncedTerm] = useState('')
useEffect(() => {
const timer = setTimeout(() => setDebouncedTerm(searchTerm), 300)
return () => clearTimeout(timer)
}, [searchTerm])
useEffect(() => {
if (debouncedTerm) fetchResults(debouncedTerm)
}, [debouncedTerm])
// Race condition handling with ignore flag
useEffect(() => {
let ignore = false
async function fetchData() {
const data = await fetch(`/api/data/${id}`).then(r => r.json())
if (!ignore) setData(data)
}
fetchData()
return () => { ignore = true }
}, [id])The Aha Moment: “Dependencies (Section 3) make effects reactive. URL state (Section 2) drives fetching. Cleanup (Section 3) prevents race conditions. Reactive data flow: URL changes → deps change → effect re-runs → new data.”
Component state doesn’t scale when multiple components need the same data. Global state (Zustand) centralizes fetching logic and data storage. Selectors enable efficient subscriptions—components only re-render when their specific slice changes.
When you need this: Shopping carts accessed across pages, user authentication state (profile shown in header, sidebar, settings), global notifications/toasts, theme settings (dark mode toggle), chat messages shared between sidebar and main view, currently selected items in multi-page workflows—any time multiple components across your app need the same data.
Concepts Applied: Global state (Zustand patterns), Selectors for efficient subscriptions, Separation of concerns, Server as data source
import { create } from 'zustand'
// Define store with async actions
const useUserStore = create((set, get) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null })
try {
const users = await fetch('/api/users').then(r => r.json())
set({ users, loading: false }) // Immutable update
} catch (error) {
set({ error: error.message, loading: false })
}
},
createUser: async (userData) => {
const newUser = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(userData)
}).then(r => r.json())
// Immutable array update
set(state => ({ users: [...state.users, newUser] }))
return newUser
}
}))
// Component usage with selectors
function UserList() {
const users = useUserStore(state => state.users)
const fetchUsers = useUserStore(state => state.fetchUsers)
useEffect(() => { fetchUsers() }, [fetchUsers])
return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
)
}Why global state scales: No prop drilling—components access the store directly. Selectors prevent unnecessary re-renders: UserList subscribes to state.users, so when currentUser updates, UserList doesn’t re-render. Centralized fetching logic in the store. State persists across component unmounts.
Advanced: Derived state with selectors
// Compute filtered users in selector
const filteredUsers = useUserStore(state =>
state.users.filter(user =>
user.name.toLowerCase().includes(state.searchTerm.toLowerCase())
)
)
// Use shallow equality for expensive computations
import { shallow } from 'zustand/shallow'
const user = useUserStore(
state => ({ name: state.name, email: state.email }),
shallow
)The Aha Moment: “Global state (Section 2) solves prop drilling. Selectors (Section 3) prevent re-renders. Immutability (Section 1) still applies—
set()creates new state objects. Fetching logic moves to the store.”
Network requests are slow. Caching solves this: store data in localStorage, show it instantly (stale), fetch fresh data in background (revalidate).
When you need this: Slow API endpoints (external services, complex database queries), frequently accessed data that doesn’t change often (product catalogs, user directories), offline-first applications, reducing API costs and rate limits, news feeds where instant load matters more than showing the absolute latest—any time perceived performance beats waiting for fresh data.
Concepts Applied: Persistence (localStorage + Zustand), Hydration timing, Immutability (comparing cached vs fresh data), Advanced state patterns
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useProductStore = create(
persist(
(set, get) => ({
products: [],
lastFetch: null,
// Stale-while-revalidate pattern
fetchProducts: async () => {
const cached = get().products
// Show cached data immediately
if (cached.length > 0) {
set({ products: cached })
}
// Fetch fresh in background
const fresh = await fetch('/api/products').then(r => r.json())
// Compare with immutability check
const hasChanged = JSON.stringify(fresh) !== JSON.stringify(cached)
if (hasChanged) {
set({ products: fresh, lastFetch: Date.now() })
}
}
}),
{ name: 'product-cache' }
)
)Why this works: User opens page → Zustand hydrates from localStorage → cached data shows instantly → fresh fetch happens in background. Immutability enables smart updates: only update if data actually changed.
Cache invalidation strategies:
// Time-based (TTL)
fetchWithTTL: async () => {
const lastFetch = get().lastFetch
const FIVE_MINUTES = 5 * 60 * 1000
if (lastFetch && (Date.now() - lastFetch) < FIVE_MINUTES) {
return // Cache fresh, skip fetch
}
const data = await fetch('/api/data').then(r => r.json())
set({ data, lastFetch: Date.now() })
}
// Invalidate on mutation
createProduct: async (newProduct) => {
await fetch('/api/products', { method: 'POST', body: JSON.stringify(newProduct) })
set({ products: [], lastFetch: null })
await get().fetchProducts()
}The Aha Moment: “Hydration (Section 2) is how caching works. Persist middleware handles hydration timing. Immutability (Section 1) enables change detection. Stale-while-revalidate gives instant loads + fresh data.”
Production applications combine every pattern: pure functions transform data, immutability enables optimistic updates, effects manage polling, cleanup prevents leaks.
When you need this: Chat applications (polling for new messages, optimistic message sending), collaborative editing tools (real-time updates, conflict resolution), social feeds (polling for new posts, infinite scroll with deduplication), live dashboards (real-time metrics), form submissions with instant feedback, e-commerce checkout (retry logic, handling network failures)—any application where users expect real-time updates and instant responsiveness.
Concepts Applied: ALL previous concepts—FP principles (Section 1), all data source patterns (Section 2), all lifecycle and effect patterns (Section 3)
Pattern 1: Polling with cleanup
function LiveMessages() {
const fetchMessages = useMessageStore(state => state.fetchMessages)
useEffect(() => {
fetchMessages() // Initial
const interval = setInterval(fetchMessages, 5000)
return () => clearInterval(interval) // Cleanup on unmount
}, [fetchMessages])
return <MessageList />
}Pattern 2: Optimistic updates with rollback
const useMessageStore = create((set, get) => ({
messages: [],
sendMessage: async (text) => {
const tempId = `temp-${Date.now()}`
const optimisticMsg = { id: tempId, text, pending: true }
// Immediate UI update (immutability)
set(state => ({ messages: [...state.messages, optimisticMsg] }))
try {
const serverMsg = await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify({ text })
}).then(r => r.json())
// Replace temp with real
set(state => ({
messages: state.messages.map(msg =>
msg.id === tempId ? serverMsg : msg
)
}))
} catch (error) {
// Rollback: remove failed message
set(state => ({
messages: state.messages.filter(msg => msg.id !== tempId)
}))
}
}
}))Pattern 3: Data normalization (pure function)
// Normalize nested data by ID
function normalize(users) {
const usersById = {}
const postsById = {}
users.forEach(user => {
const postIds = user.posts.map(post => {
postsById[post.id] = post
return post.id
})
usersById[user.id] = { ...user, posts: postIds }
})
return { usersById, postsById }
}
// Store normalized data
const useStore = create((set) => ({
usersById: {},
postsById: {},
fetchUsers: async () => {
const users = await fetch('/api/users').then(r => r.json())
const { usersById, postsById } = normalize(users)
set({ usersById, postsById })
},
// Easy immutable update!
updatePost: (postId, updates) => {
set(state => ({
postsById: {
...state.postsById,
[postId]: { ...state.postsById[postId], ...updates }
}
}))
}
}))Pattern 4: Request deduplication
const useStore = create((set, get) => ({
inflightRequests: new Set(),
fetchUsers: async () => {
if (get().inflightRequests.has('fetch-users')) return
set(state => ({
inflightRequests: new Set(state.inflightRequests).add('fetch-users')
}))
try {
const users = await fetch('/api/users').then(r => r.json())
set({ users })
} finally {
set(state => {
const newSet = new Set(state.inflightRequests)
newSet.delete('fetch-users')
return { inflightRequests: newSet }
})
}
}
}))The Aha Moment: “Every pattern combines concepts. Purity (Section 1) transforms/normalizes data. Immutability (Section 1) enables optimistic updates and rollbacks. Effects + cleanup (Section 3) manage polling and timers. Global state + selectors (Sections 2-3) coordinate everything. This is production React—composition of fundamental principles.”
Conclusion
React’s execution model isn’t magic—it’s functional programming principles composed into a predictable system. Pure functions make components testable and optimizable. Immutability enables efficient change detection. Controlled side effects prevent infinite loops. Declarative rendering eliminates manual DOM manipulation. These aren’t academic theories; they’re engineering decisions that solve real problems at scale.
From foundational patterns (basic fetch) to production architectures (optimistic updates, caching, polling), every pattern you learned builds on the same core concepts. The complexity isn’t in React—it’s in the problems you’re solving. React gives you primitives (useState, useEffect, component purity) that compose into sophisticated solutions without additional framework magic. Master the fundamentals, and production patterns become natural extensions, not mysterious incantations.
The following reference tables provide quick decision-making guidance. Use them when you’re implementing features and need to answer: “Where should this data live?”, “Which API pattern do I need?”, “Does this need cleanup?” These tables reduce mental load by mapping common scenarios to React patterns, making the right choice obvious.
When implementing a feature, the first question is: where should this data live? This table maps common scenarios to the best state location based on shareability, persistence, and scope.
| Scenario | URL State | Component State | Global State | localStorage | Reasoning |
|---|---|---|---|---|---|
| Search filters | ✅ Best | ⚠️ Works | ❌ No | ❌ No | Shareable, bookmarkable |
| Active tab/page | ✅ Best | ⚠️ Loses on refresh | ⚠️ If complex | ❌ No | Browser history integration |
| Pagination | ✅ Best | ⚠️ Resets on refresh | ❌ No | ❌ No | Shareable page numbers |
| Sort order | ✅ Best | ⚠️ Not shareable | ❌ No | ⚠️ User preference | User might want to share |
| Modal open/closed | ❌ No | ✅ Best | ❌ No | ❌ No | Temporary UI state |
| Form inputs | ❌ No | ✅ Best | ❌ No | ⚠️ Draft saving | Local until submission |
| Toggle states | ❌ No | ✅ Best | ❌ No | ❌ No | UI-only interaction |
| Hover/focus | ❌ No | ✅ Best | ❌ No | ❌ No | Ephemeral UI state |
| User profile | ❌ No | ❌ No | ✅ Best | ⚠️ Cache | Used across app |
| Shopping cart | ❌ No | ❌ No | ✅ Best | ✅ Persist | Multi-page, persist |
| Auth status | ❌ No | ❌ No | ✅ Best | ✅ Token | Global, persistent |
| Theme (dark mode) | ❌ No | ❌ No | ✅ Best | ✅ Preference | Global, persistent |
| Notifications | ❌ No | ❌ No | ✅ Best | ❌ No | Global, temporary |
| Chat messages | ⚠️ Maybe ID | ❌ No | ✅ Best | ✅ Cache | Shared + persistent |
| Draft content | ❌ No | ✅ While editing | ⚠️ If shared | ✅ Auto-save | Prevent data loss |
| Recently viewed | ❌ No | ❌ No | ⚠️ Maybe | ✅ Best | Persistent history |
| API cache | ❌ No | ❌ No | ✅ Best | ✅ Persist | Performance optimization |
| Selected items | ⚠️ If shareable | ✅ Local selection | ⚠️ Multi-page | ❌ No | Depends on scope |
Building a feature that fetches data? This table maps your requirements to the right API integration pattern, referencing the tabs above for detailed implementations.
| Feature Type | Pattern | Tab Reference | Key Characteristics | When to Use | Cleanup Needed |
|---|---|---|---|---|---|
| User profile page | Basic Fetch | Tab 1 | Fetch once on mount | Static data, simple display | ❌ No |
| Product details | Basic Fetch | Tab 1 | Load and show | Single item, no interaction | ❌ No |
| Dashboard initial load | Basic Fetch | Tab 1 | Initial data load | Page load only | ❌ No |
| Any data loading | Loading & Error | Tab 2 | Handle all UI states | Production UX required | ❌ No |
| Form submission | Loading & Error | Tab 2 | Loading → success/error | User feedback needed | ❌ No |
| Search interface | Dependencies | Tab 3 | Refetch when query changes | Search box with filters | ✅ Abort controller |
| Filtered product list | Dependencies | Tab 3 | Refetch on filter change | Category/price filters | ✅ Abort controller |
| Tabbed content | Dependencies | Tab 3 | Fetch for active tab | Dynamic tab content | ✅ Cancel old fetch |
| Master-detail view | Dependencies | Tab 3 | Fetch details on selection | User selects from list | ✅ Abort previous |
| Pagination | Dependencies | Tab 3 | Fetch new page | Page number changes | ✅ Abort if fast clicking |
| Multi-component data | Global State | Tab 4 | Shared across app | Header + sidebar + main | ❌ No (in store) |
| User auth state | Global State | Tab 4 | Accessed everywhere | Profile, settings, guards | ❌ No |
| Shopping cart | Global State | Tab 4 | Persists across pages | Multi-page checkout | ❌ No |
| Slow external API | Caching | Tab 5 | Instant perceived load | Third-party services | ❌ No (async) |
| Product catalog | Caching | Tab 5 | Data changes rarely | Reference data | ❌ No |
| News feed | Caching | Tab 5 | Stale OK, fresh better | Content apps | ❌ No |
| Offline-first app | Caching | Tab 5 | Works without network | PWAs, mobile apps | ❌ No |
| Chat application | Production | Tab 6 | Real-time + optimistic | Live messaging | ✅ Clear interval |
| Live dashboard | Production | Tab 6 | Polling for updates | Metrics, monitoring | ✅ Clear interval |
| Collaborative editing | Production | Tab 6 | Real-time sync | Google Docs style | ✅ WebSocket cleanup |
| Social feed | Production | Tab 6 | Infinite scroll + polling | Twitter/Instagram style | ✅ Multiple cleanups |
| E-commerce checkout | Production | Tab 6 | Retry + optimistic | Payment flows | ⚠️ Cancel pending |
Each task in React maps to specific functional programming principles. This table shows which FP concept applies to common operations and whether they need side effect handling.
| Task | Declarative | Pure Functions | Immutability | useEffect (Side Effects) | Example |
|---|---|---|---|---|---|
| Render UI | ✅ Describe state | ✅ Component function | ❌ Read-only | ❌ Not in render | if (loading) return <Spinner /> |
| Update state | ✅ Declare new state | ❌ setState not pure | ✅ New reference | ❌ setState is sync | setState([...items, newItem]) |
| Transform data | ⚠️ Describe result | ✅ Transform function | ✅ Don’t mutate input | ❌ Pure transform | users.map(u => ({...u, active: true})) |
| Filter array | ⚠️ Describe condition | ✅ Predicate function | ✅ Returns new array | ❌ Pure filter | users.filter(u => u.age > 18) |
| Sort array | ⚠️ Describe order | ✅ Comparator function | ✅ Use [...arr].sort() | ❌ Pure sort | [...items].sort((a,b) => a.price - b.price) |
| Fetch data | ❌ Imperative async | ❌ Side effect | ❌ External I/O | ✅ Must use | useEffect(() => { fetch(...) }, []) |
| Set timer | ❌ Imperative | ❌ Side effect | ❌ External | ✅ Must use | useEffect(() => setInterval(...)) |
| Subscribe WebSocket | ❌ Imperative | ❌ Side effect | ❌ External | ✅ Must use + cleanup | useEffect(() => { ws.on(...); return () => ws.close() }) |
| Read localStorage | ❌ Imperative | ❌ Side effect | ❌ External | ✅ Must use | useEffect(() => { const data = localStorage.get(...) }) |
| Write localStorage | ❌ Imperative | ❌ Side effect | ❌ External | ✅ Must use | useEffect(() => { localStorage.set(...) }, [data]) |
| Manipulate DOM | ❌ Never in React | ❌ Side effect | ❌ External | ✅ With ref + useEffect | useEffect(() => { ref.current.focus() }) |
| Add event listener | ❌ Imperative | ❌ Side effect | ❌ External | ✅ Must use + cleanup | useEffect(() => { window.on(...); return cleanup }) |
| Compute derived | ✅ Describe formula | ✅ Pure computation | ✅ Don’t mutate | ❌ In render or useMemo | const total = items.reduce((sum, i) => sum + i.price, 0) |
| Validate input | ✅ Describe rules | ✅ Validation function | ✅ Read-only | ❌ On change/blur | const isValid = email.includes('@') |
| Format display | ✅ Describe format | ✅ Format function | ✅ Read-only | ❌ In render | const formatted = new Date(date).toLocaleDateString() |
| Normalize data | ⚠️ Describe structure | ✅ Transform function | ✅ Create new structure | ❌ Pure transform | const byId = items.reduce((acc, i) => ({...acc, [i.id]: i}), {}) |
Every user interaction—from clicks to gestures to media controls—maps to a specific React pattern. This comprehensive reference shows the implementation approach, state management, and cleanup requirements for all common user actions.
| User Action | UX Context | State Type | Hook/Store | Lifecycle Needs | Cleanup Required | Common Use Cases |
|---|---|---|---|---|---|---|
| Click button | Instant feedback | Local | useState | No | ❌ | Submit, toggle, navigate |
| Double click | Select/zoom | Local | useState + timer | Detect vs single | ✅ Clear timer | Text selection, zoom |
| Right click | Context menu | Local | useState | Prevent default | ⚠️ If global listener | Custom menus |
| Type in input | Live feedback | Local | useState | Validation on change | ❌ | Forms, search, chat |
| Submit form | Save data | Local → API | useState + async | Handle async | ✅ Abort if unmount | Login, checkout, post |
| Toggle checkbox | Binary choice | Local/Global | useState/Zustand | No | ❌ | Settings, filters |
| Select dropdown | Choose options | Local | useState | No | ❌ | Filters, settings |
| Hover element | Tooltip/preview | Local | useState | Delay timer | ✅ Clear timer | Tooltips, previews |
| Focus input | Show hints | Local | useState | No | ❌ | Form help text |
| Blur input | Validate/save | Local | useState | Trigger validation | ⚠️ Cancel validation | Form validation |
| Drag and drop | Reorder/upload | Complex Local | useState + refs | Global drag listeners | ✅ Remove all listeners | File upload, lists |
| Touch/swipe | Mobile gestures | Local | useState | Touch tracking | ✅ Remove touch events | Carousel, pull-refresh |
| Scroll page | Infinite scroll | Local/Global | useState | Throttled listener | ✅ Remove listener | Lazy load, parallax |
| Resize window | Responsive layout | Global | Zustand | Debounced listener | ✅ Remove listener | Responsive design |
| Navigate route | Change page | URL state | React Router | Unmount old, mount new | ⚠️ Cleanup old page | SPA navigation |
| Back/Forward | Browser history | History API | React Router | Handle popstate | ⚠️ If needed | Navigation |
| Refresh page | Reload everything | All state | - | Full reinitialize | ❌ Page reloads | Fresh start |
| Tab away/return | Pause/resume | Document visibility | useState | Visibility change | ✅ Remove listener | Pause video, stop polling |
| Upload file | File selection | Local → API | useState + FormData | Track progress | ✅ Abort upload | Images, documents |
| Download file | Save to device | - | - | Create blob/link | ✅ Revoke object URL | Reports, exports |
| WebSocket message | Real-time data | Global | Zustand | Message handler | ✅ Close WebSocket | Chat, live data |
| Server-sent event | One-way updates | Global | Zustand | EventSource | ✅ Close EventSource | Notifications, feeds |
| Timer expires | Scheduled action | Local/Global | useEffect | setTimeout/Interval | ✅ Clear timer | Auto-save, session timeout |
| Keyboard shortcut | Quick actions | Global | useEffect | Key listener | ✅ Remove listener | Cmd+S, Ctrl+Z, ESC |
| Login | Authenticate | Global | Zustand | Set user, redirect | ❌ | Access control |
| Logout | End session | Global | Zustand | Clear user, redirect | ✅ Cancel subscriptions | Security |
| Change theme | Dark/light mode | Global | Zustand | Update CSS vars | ❌ | User preference |
| Grant permission | Camera/location | Local/Global | useState | Permission API | ❌ | Feature unlock |
| Play/pause media | Media controls | Local | useState + ref | Media events | ✅ Remove listeners | Video/audio players |
| Close modal | Dismiss overlay | Local | useState | ESC key, click outside | ✅ Remove listeners | Dialogs, popups |
Understanding what happens at each lifecycle stage helps you know when to fetch data, when to setup subscriptions, and when cleanup runs. This table shows all lifecycle stages and their cleanup requirements.
| Lifecycle Stage | When It Happens | Common Tasks | Cleanup Tasks | Hydration Concerns |
|---|---|---|---|---|
| Initial render | First paint | Render with defaults | N/A | Must match server HTML |
| Mount | Component appears | Start subscriptions, fetch data | Setup cleanup function | Check if client-side only |
| Props change | Parent re-renders | Update derived state | ⚠️ Cancel old operations | N/A |
| State change | setState called | Re-render UI | ⚠️ Maybe cancel old ops | N/A |
| Effect runs | After render commits | Network I/O, subscriptions, timers | Return cleanup function | Only runs client-side |
| Re-render | State/props update | Update UI with new data | N/A | N/A |
| Unmount | Component removed | N/A | Clear timers, close connections, remove listeners | N/A |
| Hydration | Client takes over SSR | Attach event handlers, restore state | N/A | Must match server exactly |
| Error boundary | Component throws | Show error UI | Cleanup bad state | Log for debugging |
| Suspense | Async loading | Show fallback UI | ⚠️ Maybe cancel | Consider streaming SSR |
Any time your effect interacts with external systems—timers, event listeners, network connections, or ongoing operations—you need cleanup. The checklist below shows what always requires cleanup and what doesn’t.
✅ Always Cleanup
setInterval / setTimeout → clearInterval / clearTimeoutaddEventListener → removeEventListenerws.close()eventSource.close()observer.disconnect()controller.abort()stream.getTracks().forEach(t => t.stop())URL.revokeObjectURL()❌ No Cleanup Needed
useState / setStatemap, filter, reduce)References
[1] CACM Staff. (2016). Facebook’s Functional Turn on Writing JavaScript. A discussion with Pete Hunt, Paul O’Shannessy, Dave Smith, and Terry Coatta. Communications of the ACM. Posted December 1, 2016. Retrieved from https://cacm.acm.org/practice/react/