React Development · Lesson 3 of 15

Hooks Deep Dive

Why Hooks Changed Everything

Before hooks (pre-React 16.8), reusing stateful logic required class components and patterns like HOCs or render props — which created wrapper hell and made code hard to follow. Hooks let you extract and reuse stateful logic without changing your component hierarchy.

The two rules that everything else flows from:

  1. Only call hooks at the top level — not inside conditions, loops, or nested functions
  2. Only call hooks from React functions — components or other hooks

useState: Beyond the Basics

JSX
// Basic: single value
const [count, setCount] = useState(0);

// Object state: common mistake vs correct approach
// ❌ Wrong: replaces entire state object
setState({ name: "Alice" });  // loses other fields

// ✅ Correct: merge with spread
setState((prev) => ({ ...prev, name: "Alice" }));

Real-World: Form State

JSX
function RegistrationForm() {
  const [form, setForm] = useState({
    email: "",
    password: "",
    confirmPassword: "",
    agreeToTerms: false,
  });
  const [errors, setErrors] = useState({});

  const updateField = (field) => (e) => {
    const value = e.target.type === "checkbox" ? e.target.checked : e.target.value;
    setForm((prev) => ({ ...prev, [field]: value }));
    // Clear error when user types
    if (errors[field]) {
      setErrors((prev) => ({ ...prev, [field]: null }));
    }
  };

  const validate = () => {
    const newErrors = {};
    if (!form.email.includes("@")) newErrors.email = "Invalid email";
    if (form.password.length < 8) newErrors.password = "Min 8 characters";
    if (form.password !== form.confirmPassword)
      newErrors.confirmPassword = "Passwords don't match";
    if (!form.agreeToTerms) newErrors.agreeToTerms = "Required";
    return newErrors;
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    const validationErrors = validate();
    if (Object.keys(validationErrors).length) {
      setErrors(validationErrors);
      return;
    }
    await registerUser(form);
  };

  return (
    <form onSubmit={handleSubmit}>
      <Input
        label="Email"
        type="email"
        value={form.email}
        onChange={updateField("email")}
        error={errors.email}
      />
      <Input
        label="Password"
        type="password"
        value={form.password}
        onChange={updateField("password")}
        error={errors.password}
      />
      {/* ... */}
    </form>
  );
}

Lazy Initialization — For Expensive Default Values

JSX
// ❌ This runs on EVERY render, not just the first
const [filters, setFilters] = useState(loadFiltersFromStorage());

// ✅ Pass a function — React only calls it once on mount
const [filters, setFilters] = useState(() => loadFiltersFromStorage());

useReducer: When useState Gets Messy

Use useReducer when state transitions are complex or state depends on previous state in multiple ways:

JSX
const initialState = {
  items: [],
  loading: false,
  error: null,
  page: 1,
  hasMore: true,
};

function cartReducer(state, action) {
  switch (action.type) {
    case "ADD_ITEM":
      const existing = state.items.find((i) => i.id === action.payload.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map((i) =>
            i.id === action.payload.id
              ? { ...i, qty: i.qty + 1 }
              : i
          ),
        };
      }
      return {
        ...state,
        items: [...state.items, { ...action.payload, qty: 1 }],
      };

    case "REMOVE_ITEM":
      return {
        ...state,
        items: state.items.filter((i) => i.id !== action.payload),
      };

    case "UPDATE_QTY":
      return {
        ...state,
        items: state.items.map((i) =>
          i.id === action.payload.id
            ? { ...i, qty: Math.max(0, action.payload.qty) }
            : i
        ).filter((i) => i.qty > 0),
      };

    case "CLEAR":
      return { ...state, items: [] };

    default:
      return state;
  }
}

function Cart() {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });

  return (
    <div>
      {state.items.map((item) => (
        <CartItem
          key={item.id}
          item={item}
          onRemove={() => dispatch({ type: "REMOVE_ITEM", payload: item.id })}
          onUpdateQty={(qty) =>
            dispatch({ type: "UPDATE_QTY", payload: { id: item.id, qty } })
          }
        />
      ))}
      <button onClick={() => dispatch({ type: "CLEAR" })}>Clear Cart</button>
    </div>
  );
}

useEffect: The Correct Mental Model

Stop thinking of useEffect as lifecycle methods. Think of it as synchronizing with external systems.

useEffect(() => {
  // Run when dependencies change — start synchronizing
  return () => {
    // Run before next effect or unmount — stop synchronizing
  };
}, [dependencies]);

Dependency Array Patterns

JSX
useEffect(() => { /* ... */ });               // Every render (rarely what you want)
useEffect(() => { /* ... */ }, []);           // Mount only
useEffect(() => { /* ... */ }, [id]);         // When id changes
useEffect(() => { /* ... */ }, [id, status]); // When either changes

Real-World: Data Fetching with Cleanup

JSX
function ProductDetails({ productId }) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // AbortController lets us cancel the fetch if productId changes
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    async function fetchProduct() {
      try {
        const res = await fetch(`/api/products/${productId}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        setProduct(data);
      } catch (err) {
        if (err.name !== "AbortError") {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchProduct();

    return () => controller.abort();
  }, [productId]);

  if (loading) return <ProductSkeleton />;
  if (error) return <ErrorMessage message={error} />;
  return <ProductView product={product} />;
}

Real-World: WebSocket Subscription

JSX
function LiveOrderStatus({ orderId }) {
  const [status, setStatus] = useState("pending");

  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/orders/${orderId}`);

    ws.onmessage = (event) => {
      const { status } = JSON.parse(event.data);
      setStatus(status);
    };

    ws.onerror = () => setStatus("error");

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

  return <StatusBadge status={status} />;
}

Real-World: Event Listeners

JSX
function useKeyboardShortcut(key, callback) {
  useEffect(() => {
    const handler = (e) => {
      if (e.key === key && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        callback();
      }
    };

    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, [key, callback]);
}

// Usage
function Editor() {
  const handleSave = useCallback(() => saveDocument(), []);
  useKeyboardShortcut("s", handleSave);
  // ...
}

useRef: Beyond DOM Access

useRef gives you a mutable container that persists across renders without triggering re-renders.

JSX
// 1. DOM access
function AutoFocusInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} placeholder="Search..." />;
}

// 2. Storing previous values
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

// 3. Tracking mounted state (avoids setState after unmount)
function DataLoader({ url }) {
  const isMounted = useRef(true);
  const [data, setData] = useState(null);

  useEffect(() => {
    return () => { isMounted.current = false; };
  }, []);

  useEffect(() => {
    fetchData(url).then((data) => {
      if (isMounted.current) setData(data);
    });
  }, [url]);
}

// 4. Storing interval/timeout IDs
function Countdown({ seconds }) {
  const [remaining, setRemaining] = useState(seconds);
  const intervalRef = useRef(null);

  const start = () => {
    intervalRef.current = setInterval(() => {
      setRemaining((prev) => {
        if (prev <= 1) {
          clearInterval(intervalRef.current);
          return 0;
        }
        return prev - 1;
      });
    }, 1000);
  };

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

  return (
    <div>
      <span>{remaining}s</span>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

useCallback and useMemo: When They Actually Help

These are optimization hooks. Only use them when you have a measurable performance problem.

useCallback — Stable Function References

JSX
// Problem: every render creates a new function reference
function ParentList({ items }) {
  // ❌ handleDelete is a new function every render
  // Child components that use React.memo will re-render anyway
  const handleDelete = (id) => deleteItem(id);

  // ✅ Stable reference — only changes when deleteItem changes
  const handleDelete = useCallback(
    (id) => deleteItem(id),
    [deleteItem]
  );

  return items.map((item) => (
    <MemoizedItem key={item.id} item={item} onDelete={handleDelete} />
  ));
}

useMemo — Expensive Computations

JSX
function ReportDashboard({ transactions, dateRange, category }) {
  // ❌ Runs on every render, even when transactions haven't changed
  const summary = computeExpensiveSummary(transactions, dateRange, category);

  // ✅ Only recomputes when dependencies change
  const summary = useMemo(
    () => computeExpensiveSummary(transactions, dateRange, category),
    [transactions, dateRange, category]
  );

  // Common use: filtering large datasets
  const filtered = useMemo(
    () =>
      transactions.filter(
        (t) =>
          t.category === category &&
          t.date >= dateRange.start &&
          t.date <= dateRange.end
      ),
    [transactions, category, dateRange.start, dateRange.end]
  );

  return <SummaryChart data={summary} transactions={filtered} />;
}

When NOT to use these hooks:

  • Simple state updates
  • Cheap computations
  • Primitives (strings, numbers) — they're already compared by value

useContext — Avoiding Prop Drilling

JSX
// 1. Create the context
interface AuthContextType {
  user: User | null;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
  isLoading: boolean;
}

const AuthContext = createContext<AuthContextType | null>(null);

// 2. Create a provider
function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // Check existing session on mount
    getCurrentUser()
      .then(setUser)
      .finally(() => setIsLoading(false));
  }, []);

  const login = async (credentials) => {
    const user = await signIn(credentials);
    setUser(user);
  };

  const logout = async () => {
    await signOut();
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, login, logout, isLoading }}>
      {children}
    </AuthContext.Provider>
  );
}

// 3. Create a custom hook (always do this — don't use useContext directly)
function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth must be used within AuthProvider");
  }
  return context;
}

// 4. Use anywhere in the tree
function UserMenu() {
  const { user, logout } = useAuth();

  return (
    <div>
      <span>{user?.name}</span>
      <button onClick={logout}>Sign Out</button>
    </div>
  );
}

Custom Hooks: The Real Power of Hooks

Custom hooks let you extract and share stateful logic. By convention, they start with use.

useLocalStorage

JSX
function useLocalStorage(key, initialValue) {
  const [stored, setStored] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = (value) => {
    try {
      const toStore = value instanceof Function ? value(stored) : value;
      setStored(toStore);
      window.localStorage.setItem(key, JSON.stringify(toStore));
    } catch (error) {
      console.error(error);
    }
  };

  return [stored, setValue];
}

// Usage
function ThemeSettings() {
  const [theme, setTheme] = useLocalStorage("theme", "light");
  return (
    <select value={theme} onChange={(e) => setTheme(e.target.value)}>
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
  );
}

useDebounce — For Search Inputs

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

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

  return debouncedValue;
}

// Usage: Search that doesn't fire on every keystroke
function SearchBar() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      searchProducts(debouncedQuery).then(setResults);
    }
  }, [debouncedQuery]);

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

useFetch — Data Fetching Pattern

JSX
function useFetch(url, options = {}) {
  const [state, setState] = useState({
    data: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    if (!url) return;

    const controller = new AbortController();
    setState({ data: null, loading: true, error: null });

    fetch(url, { ...options, signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
        return res.json();
      })
      .then((data) => setState({ data, loading: false, error: null }))
      .catch((err) => {
        if (err.name !== "AbortError") {
          setState({ data: null, loading: false, error: err.message });
        }
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

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

  if (loading) return <Skeleton />;
  if (error) return <Alert message={error} />;
  return <ProfileCard user={user} />;
}

useIntersectionObserver — Infinite Scroll

JSX
function useIntersectionObserver(ref, options = {}) {
  const [isIntersecting, setIsIntersecting] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new IntersectionObserver(([entry]) => {
      setIsIntersecting(entry.isIntersecting);
    }, options);

    observer.observe(element);
    return () => observer.disconnect();
  }, [ref, options.threshold, options.root]);

  return isIntersecting;
}

// Usage: Load more when sentinel element is visible
function InfiniteList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const sentinelRef = useRef(null);
  const isVisible = useIntersectionObserver(sentinelRef, { threshold: 0.1 });

  useEffect(() => {
    if (isVisible) {
      fetchItems(page).then((newItems) => {
        setItems((prev) => [...prev, ...newItems]);
        setPage((p) => p + 1);
      });
    }
  }, [isVisible]);

  return (
    <div>
      {items.map((item) => <ItemCard key={item.id} item={item} />)}
      <div ref={sentinelRef} style={{ height: 1 }} />
    </div>
  );
}

Interview Questions Answered

Q: What is the difference between useEffect and useLayoutEffect?

useEffect runs asynchronously after the browser has painted. useLayoutEffect runs synchronously after DOM mutations but before painting. Use useLayoutEffect only when you need to measure the DOM or prevent visual flicker (tooltip positioning, scroll restoration). Default to useEffect.

Q: Why does useEffect sometimes run twice in development?

React 18 Strict Mode mounts components twice to help detect side effects that aren't properly cleaned up. This only happens in development. Your cleanup function (the return value of useEffect) should fully undo what the effect set up.

Q: When would you choose useReducer over useState?

When: (1) state has multiple sub-values that change together, (2) next state depends on complex logic from previous state, (3) you want to co-locate state transitions with the component (like a mini state machine). Classic example: a multi-step form with validation state.

Q: Does useCallback always prevent re-renders?

No. useCallback gives you a stable function reference. But child components still re-render unless they're wrapped in React.memo. You need both: useCallback for the reference stability + React.memo on the child to actually skip the re-render.