logodev atlas
12 min read

Component Lifecycle and Hooks

Class Lifecycle vs Hooks — The Mapping

React class components have named lifecycle methods tied to specific moments in a component's existence. Function components with hooks achieve the same effects but through a different mental model: instead of subscribing to lifecycle events, hooks let you declare a side effect and optionally its cleanup — React decides when to run them based on the dependency array. The mapping below is approximate; hooks are more composable and can express patterns that have no clean class lifecycle equivalent (like subscribing/unsubscribing to the same thing at mount and update with a single useEffect call).

┌──────────────────────────────────────────────────────────────────┐
│                    Class Lifecycle                                │
│                                                                  │
│  Mounting:                                                       │
│    constructor()          →  useState(initialValue)              │
│    render()               →  function body (return JSX)          │
│    componentDidMount()    →  useEffect(() => {}, [])             │
│                                                                  │
│  Updating:                                                       │
│    shouldComponentUpdate  →  React.memo                          │
│    render()               →  function body                       │
│    componentDidUpdate()   →  useEffect(() => {}, [deps])         │
│                                                                  │
│  Unmounting:                                                     │
│    componentWillUnmount() →  useEffect(() => { return cleanup }, []) │
│                                                                  │
│  Error Handling:                                                 │
│    componentDidCatch()    →  no hook equivalent (use ErrorBoundary class) │
│    getDerivedStateFromError → no hook equivalent                 │
└──────────────────────────────────────────────────────────────────┘

useState — State in Function Components

useState is the hook that gives function components memory. It returns the current state value and a setter function. When the setter is called, React schedules a re-render and the component function runs again with the new state value. The key mental model: state is per-component-instance and persists across renders, but is reset when the component unmounts and remounts (a different key triggers this). State updates are asynchronous and batched — the new value is not available synchronously after calling the setter.

jsxfunction Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => setCount(prev => prev + 1)}>+1 (functional)</button>
    </div>
  );
}

Functional Updates — Why They Matter

jsxfunction Counter() {
  const [count, setCount] = useState(0);

  const incrementThree = () => {
    // BAD: All three read the SAME stale `count` value
    setCount(count + 1);  // count is 0 → sets to 1
    setCount(count + 1);  // count is STILL 0 → sets to 1
    setCount(count + 1);  // count is STILL 0 → sets to 1
    // Result: count goes from 0 to 1, NOT 3
  };

  const incrementThreeCorrect = () => {
    // GOOD: Each reads the latest pending state
    setCount(prev => prev + 1);  // 0 → 1
    setCount(prev => prev + 1);  // 1 → 2
    setCount(prev => prev + 1);  // 2 → 3
    // Result: count goes from 0 to 3
  };

  return <button onClick={incrementThreeCorrect}>+3</button>;
}

Lazy Initialization

jsx// BAD: computeExpensiveDefault() runs on EVERY render
const [data, setData] = useState(computeExpensiveDefault());

// GOOD: Pass a function — only runs on mount
const [data, setData] = useState(() => computeExpensiveDefault());

State Updates are Batched

jsxfunction Form() {
  const [name, setName] = useState('');
  const [age, setAge] = useState(0);

  const handleSubmit = () => {
    setName('Alice');
    setAge(30);
    // React 18+: These are batched into ONE re-render
    // Even in setTimeout, promises, and native event handlers
  };

  console.log('render'); // Logs once, not twice
}

useEffect — Side Effects

useEffect is the hook for synchronizing a component with an external system — a network connection, a browser API, a third-party library, or a subscription. It runs after the browser has painted, which means it does not block the visual update. The dependency array controls when it re-runs: no array means every render, empty array means once on mount, and a populated array means whenever any listed value changes. The cleanup function (the optional return value) is React's mechanism for tearing down the effect before re-running it — critical for avoiding memory leaks and stale callbacks from subscriptions, timers, and event listeners.

jsxuseEffect(
  () => {
    // Effect function — runs after render
    // (after DOM has been updated and painted)

    return () => {
      // Cleanup function — runs before next effect or unmount
    };
  },
  [dep1, dep2] // Dependency array
);

Dependency Array Variants

jsx// 1. No dependency array — runs after EVERY render
useEffect(() => {
  console.log('runs after every render');
});

// 2. Empty array — runs once after mount, cleanup on unmount
useEffect(() => {
  const ws = new WebSocket('ws://localhost');
  return () => ws.close(); // cleanup on unmount
}, []);

// 3. With deps — runs when any dep changes
useEffect(() => {
  fetchUser(userId);
}, [userId]); // re-runs when userId changes

Common Mistake: Object/Array Dependencies

jsxfunction Profile({ user }) {
  // BAD: user is an object — new reference every render
  useEffect(() => {
    saveToAnalytics(user);
  }, [user]); // runs EVERY render even if user data hasn't changed

  // GOOD: depend on primitive values
  useEffect(() => {
    saveToAnalytics({ id: user.id, name: user.name });
  }, [user.id, user.name]); // only runs when id or name actually change
}

useEffect vs useLayoutEffect

useEffect:
  Render → DOM update → Browser paints → useEffect runs (async)
  Use for: data fetching, subscriptions, logging

useLayoutEffect:
  Render → DOM update → useLayoutEffect runs (sync) → Browser paints
  Use for: reading DOM layout, preventing visual flicker
jsxfunction Tooltip({ targetRef }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });

  // useLayoutEffect: measure DOM before paint to prevent flicker
  useLayoutEffect(() => {
    const rect = targetRef.current.getBoundingClientRect();
    setPosition({ top: rect.bottom, left: rect.left });
  }, [targetRef]);

  return <div style={{ position: 'absolute', ...position }}>Tooltip</div>;
}

useRef — Mutable References

useRef returns a plain object { current: value } that persists across renders. Unlike state, mutating ref.current does not trigger a re-render — it is a stable mutable box. This makes it suitable for two distinct use cases: holding a reference to a DOM node (set via the ref prop), and storing mutable values that the component needs to remember but that should not cause re-renders when they change (animation frame IDs, interval handles, previous values). Think of useRef as the function component equivalent of an instance variable in a class component.

Two primary uses: DOM access and mutable values that don't trigger re-renders.

jsxfunction TextInput() {
  const inputRef = useRef(null);

  const focusInput = () => {
    inputRef.current.focus(); // Direct DOM access
  };

  return (
    <>
      <input ref={inputRef} />
      <button onClick={focusInput}>Focus</button>
    </>
  );
}

useRef as Instance Variable (No Re-render)

jsxfunction Timer() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null);

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    return () => clearInterval(intervalRef.current);
  }, []);

  const stop = () => clearInterval(intervalRef.current);

  return <p>{seconds}s <button onClick={stop}>Stop</button></p>;
}

useRef vs useState

jsxfunction RenderCounter() {
  const [stateVal, setStateVal] = useState(0);
  const refVal = useRef(0);

  const updateBoth = () => {
    setStateVal(stateVal + 1);  // triggers re-render
    refVal.current += 1;         // does NOT trigger re-render
  };

  console.log('render', { stateVal, refCurrent: refVal.current });
  // After one click: { stateVal: 1, refCurrent: 1 }
  // refVal.current is always the latest value — no stale closure issue
}

useMemo — Memoize Computed Values

useMemo caches the result of a computation between renders, recomputing only when specified dependencies change. It solves two related problems: expensive recalculations that would run on every render (e.g., filtering 10,000 items), and unstable object/array references that would cause downstream hooks or memoized children to re-run unnecessarily. The key constraint: useMemo is a performance hint, not a semantic guarantee — React may discard cached values in future concurrent rendering scenarios. Never use it to control side effects or as a substitute for useEffect.

jsxfunction FilteredList({ items, filter }) {
  // Without useMemo: filters on EVERY render (even if items/filter unchanged)
  // With useMemo: only recomputes when items or filter change
  const filtered = useMemo(() => {
    console.log('filtering...');
    return items.filter(item => item.name.includes(filter));
  }, [items, filter]);

  return <ul>{filtered.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}

When NOT to Use useMemo

jsx// UNNECESSARY: simple computation, not expensive
const fullName = useMemo(() => `${first} ${last}`, [first, last]);
// Just do: const fullName = `${first} ${last}`;

// UNNECESSARY: React already handles this efficiently
const doubled = useMemo(() => count * 2, [count]);
// Just do: const doubled = count * 2;

useCallback — Memoize Functions

useCallback memoizes a function reference so that the same function object is returned across renders as long as its dependencies do not change. A new function is created on every render in JavaScript — without useCallback, this means any prop that receives an inline function always has a new reference, breaking React.memo optimization on children. The correct mental model: useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). Only use it when the function is passed to a React.memo-wrapped child or listed in a useEffect dependency array — otherwise the overhead of dependency comparison is not justified.

jsxfunction Parent() {
  const [count, setCount] = useState(0);

  // Without useCallback: new function reference every render
  // Child (if wrapped in React.memo) would re-render unnecessarily
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []); // stable reference across renders

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <MemoChild onClick={handleClick} />
    </>
  );
}

const MemoChild = React.memo(({ onClick }) => {
  console.log('MemoChild renders');
  return <button onClick={onClick}>Click me</button>;
});

useCallback is useMemo for Functions

jsx// These are equivalent:
const handleClick = useCallback(() => { doThing(); }, [dep]);
const handleClick = useMemo(() => () => { doThing(); }, [dep]);

useReducer — Complex State Logic

useReducer is an alternative to useState for state that involves multiple related values or complex transition logic. It adopts the Redux pattern at the component level: you dispatch named actions, and a pure reducer function determines the next state based on the current state and the action. This makes state transitions explicit and testable in isolation (the reducer is just a pure function). The dispatch function has a stable identity across renders (unlike setter closures), which makes it safe to pass deeply into a component tree without causing unnecessary re-renders.

jsxconst initialState = { count: 0, step: 1 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + state.step };
    case 'decrement':
      return { ...state, count: state.count - state.step };
    case 'setStep':
      return { ...state, step: action.payload };
    case 'reset':
      return initialState;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>Count: {state.count} (step: {state.step})</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'setStep', payload: 5 })}>Step=5</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
}

When useReducer > useState

  • Multiple related state values
  • Next state depends on previous state
  • Complex state transitions (state machines)
  • When you want to pass dispatch down (stable reference, unlike setter functions with closures)

useContext — Sharing Values Across the Tree

useContext reads the nearest matching Context.Provider value above the consuming component in the tree, without requiring explicit prop passing through intermediate components. It is the solution to prop drilling for values that are conceptually global within a subtree — theme, authenticated user, locale, feature flags. The key limitation: every component that calls useContext re-renders whenever the context value reference changes, regardless of which property of the value the component actually uses. Splitting a large context into smaller, more focused contexts is the primary optimization technique.

jsxconst ThemeContext = React.createContext('light');

function App() {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={theme}>
      <Header />
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
        Toggle
      </button>
    </ThemeContext.Provider>
  );
}

function Header() {
  return <NavBar />;  // doesn't need to know about theme
}

function NavBar() {
  const theme = useContext(ThemeContext);
  return <nav className={theme}>Navigation</nav>;
}

Context Pitfall: All Consumers Re-render

jsx// PROBLEM: Every consumer re-renders when ANY value in the context changes
const AppContext = React.createContext();

function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  // BAD: new object every render → all consumers re-render
  return (
    <AppContext.Provider value={{ user, theme, setUser, setTheme }}>
      <UserPanel />  {/* re-renders when theme changes, even though it only uses user */}
      <ThemePanel /> {/* re-renders when user changes, even though it only uses theme */}
    </AppContext.Provider>
  );
}

// BETTER: Split into separate contexts
const UserContext = React.createContext();
const ThemeContext = React.createContext();

Rules of Hooks — And Why

The Rules of Hooks are not arbitrary — they exist because React relies on the stable call order of hooks within a component to associate each hook call with its corresponding state slot in the component's fiber. React does not use hook names or keys to identify hooks; it uses their position in the call order. If a hook is conditionally called, the number of hook calls can differ between renders, causing React to read the wrong state for every hook that comes after the conditional one. The ESLint plugin react-hooks/rules-of-hooks statically enforces these rules at development time.

Rule 1: Only Call Hooks at the Top Level

jsx// BAD: Hook inside condition
function Profile({ userId }) {
  if (userId) {
    useEffect(() => fetchUser(userId), [userId]); // ❌
  }
}

// WHY: React identifies hooks by their CALL ORDER (index).
// Render 1: useState (index 0), useEffect (index 1)
// Render 2 (if skipped): useState (index 0) → where's useEffect?
// React's internal hook list gets misaligned → bugs

// GOOD: Condition inside the hook
function Profile({ userId }) {
  useEffect(() => {
    if (userId) fetchUser(userId);
  }, [userId]); // ✅
}

Rule 2: Only Call Hooks from React Functions

jsx// BAD: Hook in regular function
function getUser() {
  const [user, setUser] = useState(null); // ❌
}

// GOOD: Hook in component or custom hook
function useUser() {
  const [user, setUser] = useState(null); // ✅ (custom hook)
  return user;
}

Custom Hooks — Reusable Logic

A custom hook is any function whose name starts with use and that calls other React hooks. They are the primary mechanism for extracting and sharing stateful logic between components without changing the component hierarchy. Before hooks, this required higher-order components or render props — patterns that added nesting and made component trees harder to follow. Custom hooks give you the same reuse capability with a clean function-call API. Each component that calls a custom hook gets its own independent copy of the hook's state.

Pattern: Extracting Shared Logic

jsxfunction useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored !== null ? JSON.parse(stored) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [lang, setLang] = useLocalStorage('lang', 'en');
  // ...
}

Pattern: Async Data Fetching

jsxfunction useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;  // prevent state update on unmounted component
    setLoading(true);

    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(json => {
        if (!cancelled) {
          setData(json);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });

    return () => { cancelled = true; };
  }, [url]);

  return { data, loading, error };
}

// Usage
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <p>{user.name}</p>;
}

Pattern: useDebounce

jsxfunction useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// Usage
function Search() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      searchAPI(debouncedQuery);
    }
  }, [debouncedQuery]); // only fires 300ms after user stops typing

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

Interview Quick Hits

Q: Why can't you call hooks inside loops or conditions? React tracks hooks by call index. If the number or order of hook calls changes between renders, React's internal state array gets out of sync.

Q: Does useEffect run before or after paint? After. The browser paints first, then useEffect fires asynchronously. Use useLayoutEffect if you need to run before paint.

Q: What's the difference between useRef and a module-level variable? useRef is per-component-instance. A module-level variable is shared across all instances of that component.

Q: When does the cleanup function of useEffect run? Before the next execution of the effect (when deps change) AND when the component unmounts.

Q: Is setState synchronous? No. State updates are scheduled and batched. You won't see the new value until the next render. Use functional updates (setCount(prev => prev + 1)) to access the latest state.

[prev·next]