Building Agent Interfaces II: Functional Foundations for Data-Driven UI

Author
Fei Wang
Published
Affiliations
The AI Engineer

React's data-driven UI model stems from functional programming—pure functions, immutability, and controlled side effects form the execution backbone. This guide builds a complete mental model through three progressive layers: FP principles that shape React's design, state management patterns (URL, component, global, persistent), and API integration from basic fetching to production patterns. Interactive examples and reference tables transform abstract concepts into practical decision-making tools, preparing you to build any complex interface from foundational patterns.

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
Imperative (Vanilla JS)

Step-by-step instructions: Find element, update text, toggle class

Declarative (React)

Describe the result: UI = f(state)

React's Job

React handles the DOM updates automatically

The Formula: 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');
  }
});
Problem: You must manually update each DOM element. Easy to forget an update or create bugs.
// 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>
  );
}
Solution: Change state once, React updates everything automatically. No manual DOM manipulation.

Why This Matters

Predictability

Same state always produces same UI. No hidden DOM state.

Less Code

One state update instead of multiple DOM manipulations.

Fewer Bugs

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
Same Input → Same Output

Deterministic behavior, no randomness or external dependencies

No Side Effects

Doesn't modify variables outside its scope

No External State

Output depends only on input parameters

In React:

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
Why it's pure: No external dependencies. Given a=2 and b=3, always returns 5. Predictable and testable.
// 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!
Why it's impure: Output depends on external `count` variable. Same input (no parameters) produces different outputs. Unpredictable.

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
Pure component: Same props always produce same JSX. React can optimize by memoizing results.
// 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
Impure component: Modifies external state. Output is unpredictable. Breaks React's rendering model.

Why Purity Matters in React

Predictable Rendering

React can call your component multiple times during rendering. Purity guarantees consistent results.

Easy Testing

Test components like functions: provide props, verify JSX output. No setup or mocking needed.

Optimizations

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
Reference Comparison

React uses === to check if state changed

Mutation Keeps Same Reference

Modifying an object doesn't change its memory address

New Object = New Reference

Creating new objects triggers re-renders

Common Patterns:
  • Spread for arrays - Add to array
  • array.filter() - Remove from array
  • array.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

Total todos:
No todos yet. Add one above!

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>
  );
}
Problem: todos.push() mutates the array. setTodos receives the same reference (same memory address). React compares old === new, sees they're identical, skips re-render.
// 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>
  );
}
Solution: Spread operator creates a new array with new reference. React compares old !== new, detects change, triggers re-render.

Common Immutable Update Patterns

Add to Array Spread syntax to create new array
Remove from Array array.filter(item => item.id !== id)
Update Array Item array.map() with spread in callback
Update Object 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
Data Fetching

Network requests to APIs

Subscriptions

WebSocket, setInterval, addEventListener

DOM Manipulation

Direct access to document or window

Browser Storage

Reading/writing localStorage

The Solution:

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>;
}
Problem: Fetch runs on every render. Setting state triggers re-render. Fetch runs again. Infinite loop. React may also call your component multiple times during rendering.

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>;
}
Solution: useEffect runs AFTER rendering completes. Empty dependency array means it runs only once on mount. No infinite loop.

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

1. Component Renders

React calls your component function, generates JSX

2. DOM Updates

React commits changes to the DOM

3. Effects Run

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
Complete the React tutorial ✓ Completed

What happened? One click triggered 4 UI changes:

  1. Checkbox state (checked/unchecked)
  2. Text decoration (strikethrough)
  3. Completed badge (show/hide)
  4. 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)
Vanilla JavaScript 15 lines
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';
});
React 7 lines
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

15
7
Simple Toggle
28
9
+ Priority Badge
42
11
+ Due Date
Vanilla JS React

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

URL State
Address Bar
Browser Window
Component State
React Memory
Global State
Shared Store
Browser Storage
localStorage
Server Data
API / Database

Component State

Where: Lives in React component memory, managed by useState hook
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>
  );
}
Where: Lives in a shared store (like Zustand) accessible from any component
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

Where: Persisted in browser's localStorage, survives page refreshes and browser restarts
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

Where: Lives on external server/database, fetched via API calls
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

Where: Lives in browser's address bar as query parameters or route segments
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
Shareable

Users can copy and share links

Visible

State is visible in address bar

Persistent

Survives page refresh

Reactive

Updates browser history

Try This:

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!

Current URL:
Showing items, sorted by
Shareable Filters

Users can share search results, active tabs, or specific views via URL

Bookmarkable State

Users can bookmark specific application states and return to them later

Browser History

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
Temporary

Resets when component unmounts

Reactive

Updates trigger automatic re-renders

Component-Scoped

Only this component and children can access it

In-Memory

Lost on page refresh

Try This:

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
This content visibility is controlled by component state

Current state: isVisible =

UI-Only State

Modal open/closed, dropdown expanded, form validation errors - state that doesn't need to persist

Temporary Data

Form inputs before submission, search queries, filters that reset on navigation

Component-Specific Logic

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 →

Count:
Name:
Total Re-renders:

Store (In-Memory Object)

count:
name: ""
Subscribers:
ComponentA (all) ComponentB (count only)
⚡ Notifying: ComponentA + ComponentB (count changed) ⚡ Notifying: ComponentA only (name changed)
ComponentA
Full Store
const store = useStore() return <div>{store.count}</div>
Display:

Subscribes to: ALL properties (inefficient)

RE-RENDERING
ComponentB
Selector
const count = useStore(s => s.count) return <div>{count}</div>
Display:

Subscribes to: count only (optimized)

RE-RENDERING

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.

Step of

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.

0ms Server HTML

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.

~50ms JS Downloads

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.

~100ms React Hydrates

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.

~150ms Store Hydrates

✓ 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().

~200ms Re-render

Components re-render with correct state

✓ UI matches localStorage

Complete Hydrated

✓ 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>

Parent

Props: collapsed: boolean

Role: Container for navigation and user profile

return <div><ConversationList /> <UserProfile /></div>

Parent Responsibilities:

  • Handles onSelect → Updates activeId state
  • Handles onLogout → Clears user session
  • Manages sidebar collapse state
  • Passes state down to children as props

<ConversationList>

Child

Props: conversations[], activeId, onSelect

Role: Maps over conversations array

conversations.map(c => <ConversationItem key={c.id} ...props />)

↓ Props flow DOWN to children

<ConversationItem>

Grandchild

Props: id, title, isActive, onClick

Role: Renders single conversation preview

↑ Click event → onClick → Parent updates activeId

<UserProfile>

Child

Props: 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
Mount

Component appears in the DOM for the first time

Update

Props or state change → React re-renders

Unmount

Component is removed from the DOM

What Triggers Re-renders:
  • 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

Mount

Component appears

Update

State/props change

Unmount

Component removed

Live Component

Render count:

Component has been unmounted. Cleanup functions would run here.

Console Output:
Mount: Component function called (render #1)
Update: Component function called (render #)
Unmount: Cleanup functions run

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

Common Use Cases:
  • 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>;
}
Use case: Initial data loading. Effect runs once when component mounts, never again. Perfect for fetching user profiles, configuration, or static data.
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>
  );
}
Use case: Dependent data fetching. When userId prop changes, fetch new posts. Effect re-runs automatically when dependencies change.
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>;
}
Use case: Subscriptions and event listeners. Cleanup function prevents memory leaks by closing connections when component unmounts or roomId changes.
Common Mistakes to Avoid
Mistake 1: Missing Dependencies
// 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
Mistake 2: No Dependency Array (Infinite Loop)
// 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]
Pro tip: Use eslint-plugin-react-hooks to catch missing dependencies automatically. It will warn you when your dependency array is incorrect.

When Do Effects Run?

1. Render

Component function runs

2. DOM Update

React commits to DOM

3. Browser Paint

User sees changes

4. Effects Run

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 shallow when 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=vuequery 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.

ScenarioURL StateComponent StateGlobal StatelocalStorageReasoning
Search filters✅ Best⚠️ Works❌ No❌ NoShareable, bookmarkable
Active tab/page✅ Best⚠️ Loses on refresh⚠️ If complex❌ NoBrowser history integration
Pagination✅ Best⚠️ Resets on refresh❌ No❌ NoShareable page numbers
Sort order✅ Best⚠️ Not shareable❌ No⚠️ User preferenceUser might want to share
Modal open/closed❌ No✅ Best❌ No❌ NoTemporary UI state
Form inputs❌ No✅ Best❌ No⚠️ Draft savingLocal until submission
Toggle states❌ No✅ Best❌ No❌ NoUI-only interaction
Hover/focus❌ No✅ Best❌ No❌ NoEphemeral UI state
User profile❌ No❌ No✅ Best⚠️ CacheUsed across app
Shopping cart❌ No❌ No✅ Best✅ PersistMulti-page, persist
Auth status❌ No❌ No✅ Best✅ TokenGlobal, persistent
Theme (dark mode)❌ No❌ No✅ Best✅ PreferenceGlobal, persistent
Notifications❌ No❌ No✅ Best❌ NoGlobal, temporary
Chat messages⚠️ Maybe ID❌ No✅ Best✅ CacheShared + persistent
Draft content❌ No✅ While editing⚠️ If shared✅ Auto-savePrevent data loss
Recently viewed❌ No❌ No⚠️ Maybe✅ BestPersistent history
API cache❌ No❌ No✅ Best✅ PersistPerformance optimization
Selected items⚠️ If shareable✅ Local selection⚠️ Multi-page❌ NoDepends 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 TypePatternTab ReferenceKey CharacteristicsWhen to UseCleanup Needed
User profile pageBasic FetchTab 1Fetch once on mountStatic data, simple display❌ No
Product detailsBasic FetchTab 1Load and showSingle item, no interaction❌ No
Dashboard initial loadBasic FetchTab 1Initial data loadPage load only❌ No
Any data loadingLoading & ErrorTab 2Handle all UI statesProduction UX required❌ No
Form submissionLoading & ErrorTab 2Loading → success/errorUser feedback needed❌ No
Search interfaceDependenciesTab 3Refetch when query changesSearch box with filters✅ Abort controller
Filtered product listDependenciesTab 3Refetch on filter changeCategory/price filters✅ Abort controller
Tabbed contentDependenciesTab 3Fetch for active tabDynamic tab content✅ Cancel old fetch
Master-detail viewDependenciesTab 3Fetch details on selectionUser selects from list✅ Abort previous
PaginationDependenciesTab 3Fetch new pagePage number changes✅ Abort if fast clicking
Multi-component dataGlobal StateTab 4Shared across appHeader + sidebar + main❌ No (in store)
User auth stateGlobal StateTab 4Accessed everywhereProfile, settings, guards❌ No
Shopping cartGlobal StateTab 4Persists across pagesMulti-page checkout❌ No
Slow external APICachingTab 5Instant perceived loadThird-party services❌ No (async)
Product catalogCachingTab 5Data changes rarelyReference data❌ No
News feedCachingTab 5Stale OK, fresh betterContent apps❌ No
Offline-first appCachingTab 5Works without networkPWAs, mobile apps❌ No
Chat applicationProductionTab 6Real-time + optimisticLive messaging✅ Clear interval
Live dashboardProductionTab 6Polling for updatesMetrics, monitoring✅ Clear interval
Collaborative editingProductionTab 6Real-time syncGoogle Docs style✅ WebSocket cleanup
Social feedProductionTab 6Infinite scroll + pollingTwitter/Instagram style✅ Multiple cleanups
E-commerce checkoutProductionTab 6Retry + optimisticPayment 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.

TaskDeclarativePure FunctionsImmutabilityuseEffect (Side Effects)Example
Render UI✅ Describe state✅ Component function❌ Read-only❌ Not in renderif (loading) return <Spinner />
Update state✅ Declare new state❌ setState not pure✅ New reference❌ setState is syncsetState([...items, newItem])
Transform data⚠️ Describe result✅ Transform function✅ Don’t mutate input❌ Pure transformusers.map(u => ({...u, active: true}))
Filter array⚠️ Describe condition✅ Predicate function✅ Returns new array❌ Pure filterusers.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 useuseEffect(() => { fetch(...) }, [])
Set timer❌ Imperative❌ Side effect❌ External✅ Must useuseEffect(() => setInterval(...))
Subscribe WebSocket❌ Imperative❌ Side effect❌ External✅ Must use + cleanupuseEffect(() => { ws.on(...); return () => ws.close() })
Read localStorage❌ Imperative❌ Side effect❌ External✅ Must useuseEffect(() => { const data = localStorage.get(...) })
Write localStorage❌ Imperative❌ Side effect❌ External✅ Must useuseEffect(() => { localStorage.set(...) }, [data])
Manipulate DOM❌ Never in React❌ Side effect❌ External✅ With ref + useEffectuseEffect(() => { ref.current.focus() })
Add event listener❌ Imperative❌ Side effect❌ External✅ Must use + cleanupuseEffect(() => { window.on(...); return cleanup })
Compute derived✅ Describe formula✅ Pure computation✅ Don’t mutate❌ In render or useMemoconst total = items.reduce((sum, i) => sum + i.price, 0)
Validate input✅ Describe rules✅ Validation function✅ Read-only❌ On change/blurconst isValid = email.includes('@')
Format display✅ Describe format✅ Format function✅ Read-only❌ In renderconst formatted = new Date(date).toLocaleDateString()
Normalize data⚠️ Describe structure✅ Transform function✅ Create new structure❌ Pure transformconst 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 ActionUX ContextState TypeHook/StoreLifecycle NeedsCleanup RequiredCommon Use Cases
Click buttonInstant feedbackLocaluseStateNoSubmit, toggle, navigate
Double clickSelect/zoomLocaluseState + timerDetect vs single✅ Clear timerText selection, zoom
Right clickContext menuLocaluseStatePrevent default⚠️ If global listenerCustom menus
Type in inputLive feedbackLocaluseStateValidation on changeForms, search, chat
Submit formSave dataLocal → APIuseState + asyncHandle async✅ Abort if unmountLogin, checkout, post
Toggle checkboxBinary choiceLocal/GlobaluseState/ZustandNoSettings, filters
Select dropdownChoose optionsLocaluseStateNoFilters, settings
Hover elementTooltip/previewLocaluseStateDelay timer✅ Clear timerTooltips, previews
Focus inputShow hintsLocaluseStateNoForm help text
Blur inputValidate/saveLocaluseStateTrigger validation⚠️ Cancel validationForm validation
Drag and dropReorder/uploadComplex LocaluseState + refsGlobal drag listeners✅ Remove all listenersFile upload, lists
Touch/swipeMobile gesturesLocaluseStateTouch tracking✅ Remove touch eventsCarousel, pull-refresh
Scroll pageInfinite scrollLocal/GlobaluseStateThrottled listener✅ Remove listenerLazy load, parallax
Resize windowResponsive layoutGlobalZustandDebounced listener✅ Remove listenerResponsive design
Navigate routeChange pageURL stateReact RouterUnmount old, mount new⚠️ Cleanup old pageSPA navigation
Back/ForwardBrowser historyHistory APIReact RouterHandle popstate⚠️ If neededNavigation
Refresh pageReload everythingAll state-Full reinitialize❌ Page reloadsFresh start
Tab away/returnPause/resumeDocument visibilityuseStateVisibility change✅ Remove listenerPause video, stop polling
Upload fileFile selectionLocal → APIuseState + FormDataTrack progress✅ Abort uploadImages, documents
Download fileSave to device--Create blob/link✅ Revoke object URLReports, exports
WebSocket messageReal-time dataGlobalZustandMessage handler✅ Close WebSocketChat, live data
Server-sent eventOne-way updatesGlobalZustandEventSource✅ Close EventSourceNotifications, feeds
Timer expiresScheduled actionLocal/GlobaluseEffectsetTimeout/Interval✅ Clear timerAuto-save, session timeout
Keyboard shortcutQuick actionsGlobaluseEffectKey listener✅ Remove listenerCmd+S, Ctrl+Z, ESC
LoginAuthenticateGlobalZustandSet user, redirectAccess control
LogoutEnd sessionGlobalZustandClear user, redirect✅ Cancel subscriptionsSecurity
Change themeDark/light modeGlobalZustandUpdate CSS varsUser preference
Grant permissionCamera/locationLocal/GlobaluseStatePermission APIFeature unlock
Play/pause mediaMedia controlsLocaluseState + refMedia events✅ Remove listenersVideo/audio players
Close modalDismiss overlayLocaluseStateESC key, click outside✅ Remove listenersDialogs, 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 StageWhen It HappensCommon TasksCleanup TasksHydration Concerns
Initial renderFirst paintRender with defaultsN/AMust match server HTML
MountComponent appearsStart subscriptions, fetch dataSetup cleanup functionCheck if client-side only
Props changeParent re-rendersUpdate derived state⚠️ Cancel old operationsN/A
State changesetState calledRe-render UI⚠️ Maybe cancel old opsN/A
Effect runsAfter render commitsNetwork I/O, subscriptions, timersReturn cleanup functionOnly runs client-side
Re-renderState/props updateUpdate UI with new dataN/AN/A
UnmountComponent removedN/AClear timers, close connections, remove listenersN/A
HydrationClient takes over SSRAttach event handlers, restore stateN/AMust match server exactly
Error boundaryComponent throwsShow error UICleanup bad stateLog for debugging
SuspenseAsync loadingShow fallback UI⚠️ Maybe cancelConsider 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 / setTimeoutclearInterval / clearTimeout
addEventListenerremoveEventListener
WebSocket → ws.close()
EventSource → eventSource.close()
IntersectionObserver → observer.disconnect()
AbortController → controller.abort()
Media streams → stream.getTracks().forEach(t => t.stop())
Object URLs → URL.revokeObjectURL()

❌ No Cleanup Needed

useState / setState
Pure computations
Component rendering
Array methods (map, filter, reduce)
Derived state calculations
Data transformations

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/