# Effects and Refs
# Rules of hooks
Hooks (useState, useEffect, useRef, custom use* functions) may only be called:
- at the top level of a function component or custom hook—never inside
if, loops, or nested functions; - in the same order every render (which the top-level rule guarantees).
React matches hook calls to their state by call order, so conditional hooks corrupt the component's state.
# What effects are for
useEffect synchronizes a component with an external system: a network subscription, a timer, a DOM API, a third-party widget. Do not use effects for:
- derived values—compute them during render (
const visible = items.filter(...)); - responding to user events—that logic belongs in the event handler.
import { useEffect, useState } from 'react';
function ElapsedCounter() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
return () => clearInterval(id);
}, []);
return <p>{seconds} s elapsed</p>;
}
The dependency array lists the reactive values the effect uses—props and state that can change between renders. Module constants and state setters are stable and are excluded by convention. With [] the effect runs after the component's first commit and its cleanup runs on unmount; in development, React 18 StrictMode additionally runs setup → cleanup → setup once on mount to expose missing cleanup. That extra cycle is dev-only and does not happen in production builds. The cleanup also runs before each re-run when dependencies change.
# Fetching data
The effect callback itself cannot be async (it would return a Promise instead of a cleanup function); define an async function inside it. Guard the request with AbortController, check response.ok, and validate the unknown JSON before trusting its shape. The example assumes an endpoint /api/products that returns Product[] JSON—that contract is illustrative, not implemented by these snippets; adapt the URL and validation to your backend.
import * as React from 'react';
interface Product {
id: number;
name: string;
price: number;
}
function isProduct(value: unknown): value is Product {
if (typeof value !== 'object' || value === null) {
return false;
}
const p = value as Record<string, unknown>;
return typeof p.id === 'number' && Number.isFinite(p.id)
&& typeof p.name === 'string'
&& typeof p.price === 'number' && Number.isFinite(p.price);
}
export default function ProductList() {
const [products, setProducts] = React.useState<Product[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
const controller = new AbortController();
const load = async () => {
try {
const response = await fetch('/api/products', {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data: unknown = await response.json();
if (!Array.isArray(data) || !data.every(isProduct)) {
throw new Error('Unexpected response shape');
}
if (!controller.signal.aborted) {
setProducts(data);
}
} catch (e) {
if (!controller.signal.aborted) {
setError(e instanceof Error ? e.message : 'Failed to load');
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
};
load();
return () => controller.abort();
}, []);
if (loading) return <p>Loading…</p>;
if (error) return <p role="alert">{error}</p>;
if (products.length === 0) return <p>No products yet.</p>;
return (
<ul>
{products.map((p) => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
Note all four UI states—loading, error, empty, success—and that cleanup plus the aborted guards make the component abandon obsolete request results rather than blindly setting state.
A tempting shorthand is writing React.useEffect(async () => { ... }) directly. That does not work: an async function returns a Promise, but React expects the effect callback to return nothing or a cleanup function. The inner-async-function pattern above is the fix.
# Refs for DOM access
useRef returns a mutable { current } box. Changing current does not re-render—use refs for DOM handles, timers, or previous values, not for data the UI displays (that is state).
import { useRef } from 'react';
function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<>
<label>
Search:
<input ref={inputRef} type="search" />
</label>
<button type="button" onClick={focusInput}>Focus</button>
</>
);
}
current is null until React attaches the element, so always guard with ?. or an if before dereferencing. Initializing with useRef<HTMLInputElement>(null) also gives the ref the RefObject<HTMLInputElement> shape React expects for element refs; calling useRef<HTMLInputElement>() with no argument produces a different type that won't assign cleanly to ref.
# Class lifecycle equivalents
Class components use lifecycle methods where function components use effects—but this is a rough comparison, not exact equivalents. Effects run after the commit to the DOM and their cleanup is tied to their own dependencies, not to a one-to-one lifecycle mapping:
| Class method | Roughly comparable hook usage |
|---|---|
componentDidMount | useEffect(fn, []) setup |
componentWillUnmount | the cleanup return |
componentDidUpdate | useEffect re-running when listed dependencies change |
On a class component you would add logging like this to observe the ordering:
componentDidMount(): void {
console.log('mounted');
}
componentWillUnmount(): void {
console.log('unmounted');
}
Try it: add a cleanup that logs, mount and unmount the component (e.g. toggle {show && <ProductList />}), and observe the order of log lines. Then enable React.StrictMode and explain why the effect runs its setup and cleanup an extra time on mount in development.