Skip to content

React Hooks Deep Dive — useEffect, useMemo, useCallback, useRef Explained

DodaTech 6 min read

In this tutorial, you'll learn about React Hooks Deep Dive. We cover key concepts, practical examples, and best practices.

What You'll Learn

This deep dive covers four essential React hooks — useEffect for side effects, useMemo and useCallback for performance optimization, and useRef for mutable references — with advanced patterns and TypeScript examples.

Why It Matters

Mastering these hooks beyond the basics separates intermediate React developers from advanced ones. Misusing useEffect causes infinite loops and stale closures. Skipping useMemo and useCallback leads to unnecessary re-renders that tank performance in large applications.

Real-World Use

A dashboard app where useEffect fetches real-time stock data, useMemo memoizes expensive chart calculations, useCallback prevents the chart component from re-rendering on every parent update, and useRef captures the previous data set for comparison.

Learning Path

flowchart LR
    A[React Basics] --> B[useState Hook]
    B --> C[useEffect Hook]
    C --> D[Hooks Deep Dive]
    D --> E[Custom Hooks]
    E --> F[Performance Optimization]
    F --> G[Final Project]
    D -->|You are here| D

useEffect — Side Effect Lifecycle

The useEffect hook manages side effects in React functional components. It replaces componentDidMount, componentDidUpdate, and componentWillUnmount from class components.

import { useState, useEffect } from "react";

type User = { id: number; name: string; email: string };

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;

    async function loadUser() {
      setLoading(true);
      setError(null);
      try {
        const res = await fetch(`/api/users/${userId}`);
        if (!res.ok) throw new Error("Failed to fetch");
        const data: User = await res.json();
        if (!cancelled) setUser(data);
      } catch (err) {
        if (!cancelled) setError((err as Error).message);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

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

  if (loading) return <div>Loading user...</div>;
  if (error) return <div role="alert">Error: {error}</div>;
  return <h1>{user?.name}</h1>;
}

Expected output: When userId changes, the component shows "Loading user...", fetches data, then displays the user name. If the component unmounts mid-request, the cleanup function prevents setting state on an unmounted component.

Effect Cleanup Patterns

import { useEffect, useRef, useState } from "react";

function ConnectionStatus({ url }: { url: string }) {
  const [isConnected, setIsConnected] = useState(false);
  const retryCount = useRef(0);

  useEffect(() => {
    const ws = new WebSocket(url);

    ws.onopen = () => setIsConnected(true);
    ws.onclose = () => {
      setIsConnected(false);
      retryCount.current += 1;
    };
    ws.onerror = () => setIsConnected(false);

    return () => {
      ws.close();
    };
  }, [url]);

  return (
    <div>
      Status: {isConnected ? "Connected" : "Disconnected"}
      (retries: {retryCount.current})
    </div>
  );
}

Expected output: Renders "Status: Connected" or "Status: Disconnected" based on WebSocket state. When url changes or the component unmounts, the cleanup closes the old connection.

useMemo — Memoizing Expensive Computations

useMemo caches the result of a computation between re-renders. Only recomputes when dependencies change.

import { useMemo, useState } from "react";

interface Transaction {
  id: number;
  amount: number;
  category: string;
  date: string;
}

function SpendingReport({ transactions }: { transactions: Transaction[] }) {
  const [currency, setCurrency] = useState("USD");

  const totalsByCategory = useMemo(() => {
    console.log("Computing category totals...");
    return transactions.reduce<Record<string, number>>((acc, t) => {
      acc[t.category] = (acc[t.category] || 0) + t.amount;
      return acc;
    }, {});
  }, [transactions]);

  const highestCategory = useMemo(() => {
    return Object.entries(totalsByCategory).sort((a, b) => b[1] - a[1])[0];
  }, [totalsByCategory]);

  return (
    <div>
      <select value={currency} onChange={(e) => setCurrency(e.target.value)}>
        <option value="USD">USD</option>
        <option value="EUR">EUR</option>
      </select>
      <h2>Highest Spending: {highestCategory?.[0]}</h2>
      <ul>
        {Object.entries(totalsByCategory).map(([cat, total]) => (
          <li key={cat}>{cat}: ${total.toFixed(2)}</li>
        ))}
      </ul>
    </div>
  );
}

Expected output: The category totals are computed only when transactions changes. Changing the currency dropdown does NOT trigger re-computation. The console log appears only on transaction updates, demonstrating memoization at work.

useCallback — Stable Function References

useCallback returns a memoized version of a callback function. Essential when passing callbacks to child components that rely on reference equality to prevent re-renders.

import { useCallback, useState, memo } from "react";

const ExpensiveList = memo(function ExpensiveList({
  items,
  onSelect,
}: {
  items: { id: number; label: string }[];
  onSelect: (id: number) => void;
}) {
  console.log("ExpensiveList rendered");
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>
          <button onClick={() => onSelect(item.id)}>{item.label}</button>
        </li>
      ))}
    </ul>
  );
});

function SearchPage() {
  const [query, setQuery] = useState("");
  const [selectedId, setSelectedId] = useState<number | null>(null);

  const items = [
    { id: 1, label: "React hooks" },
    { id: 2, label: "TypeScript" },
    { id: 3, label: "Performance" },
  ];

  const handleSelect = useCallback((id: number) => {
    setSelectedId(id);
  }, []);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <p>Selected: {selectedId ?? "none"}</p>
      <ExpensiveList items={items} onSelect={handleSelect} />
    </div>
  );
}

Expected output: "ExpensiveList rendered" appears only once on initial mount. Typing in the search input does NOT re-render ExpensiveList because handleSelect maintains a stable reference via useCallback and the component is wrapped in memo.

useRef — Mutable References and DOM Access

useRef persists mutable values across renders without causing re-renders. It also provides direct access to DOM elements.

import { useRef, useEffect, useState } from "react";

function Stopwatch() {
  const [elapsed, setElapsed] = useState(0);
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const startTimeRef = useRef<number>(0);

  function start() {
    startTimeRef.current = Date.now() - elapsed;
    intervalRef.current = setInterval(() => {
      setElapsed(Date.now() - startTimeRef.current);
    }, 10);
  }

  function stop() {
    if (intervalRef.current) {
      clearInterval(intervalRef.current);
      intervalRef.current = null;
    }
  }

  function reset() {
    stop();
    setElapsed(0);
  }

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

  return (
    <div>
      <p>{(elapsed / 1000).toFixed(2)}s</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

Expected output: The stopwatch displays elapsed time in seconds with two decimal places. Start begins the timer, Stop pauses it without resetting, and Reset clears both the display and internal reference. The intervalRef stores the interval ID without causing re-renders when it changes.

Common Pitfalls

1. Missing Dependencies in useEffect

Omitting a dependency that is used inside useEffect causes stale closures and bugs. The React exhaustive-deps ESLint rule catches these.

2. Over-using useMemo and useCallback

Not every computation needs memoization. The overhead of useMemo can exceed the cost of the computation for simple operations. Profile before optimizing.

3. Reading useRef in JSX

Changing ref.current does not trigger re-renders. If the UI must update when the ref value changes, store the value in state instead.

4. Infinite Loops with useEffect

Updating state inside useEffect without specifying dependencies or with a dependency that changes every render causes infinite loops.

Practice Questions

What is the difference between useEffect and useLayoutEffect?

useEffect runs after the browser paints, making it suitable for most side effects. useLayoutEffect runs synchronously after DOM mutations but before the browser paints, making it necessary for reading layout and synchronously re-rendering.

When should I use useMemo vs useCallback?

useMemo memoizes a computed value (returns the value). useCallback memoizes a function (returns the function reference). Use useCallback when passing callbacks to memoized children. Use useMemo for expensive computations.

Can useRef store more than DOM references?

Yes. useRef is a general-purpose container for mutable values that persist across renders. You can store interval IDs, previous state values, WebSocket instances, or any mutable data without triggering re-renders.

Challenge

Build a usePrevious custom hook that tracks the previous value of a state or prop. It should use useRef internally and return undefined on the first render. Write a component that displays both the current and previous search query.

Real-World Task

Optimize a product listing page that renders 500 items. Add useMemo to filter and sort the product list based on user input. Wrap the item component in React.memo and stabilize the onAddToCart callback with useCallback. Measure the render count with useRef to confirm the optimization works.


This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — production React applications where hooks manage real-time data, performance-critical rendering, and secure DOM access.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro