React Hooks: A Deep Dive into Modern React Development
Explore React Hooks and learn how to write functional components with state and effects.

React Hooks changed how we write components fundamentally. Before them, stateful logic lived in class components — verbose, entangled, and awkward to share. Hooks let you extract that logic into plain functions that compose freely. This guide goes beyond the basics and into the subtleties that separate working code from well-structured code.
The Rules of Hooks
Two rules, enforced by the eslint-plugin-react-hooks linter. Violating them causes bugs that are genuinely hard to diagnose:
- Call hooks at the top level. Never inside conditionals, loops, or nested functions. React relies on the call order to associate state with the right hook across renders.
- Call hooks only from React functions. That means functional components and custom hooks — not regular JavaScript functions.
// Bug — conditional hook call
function UserCard({ userId }: { userId?: string }) {
if (!userId) return null; // ❌ return before hooks
const [user, setUser] = useState(null);
}
// Fix — guard after hooks
function UserCard({ userId }: { userId?: string }) {
const [user, setUser] = useState(null);
if (!userId) return null; // ✅
}
useState
useState holds a value and a function to update it. Every call to the setter schedules a re-render with the new value.
const [count, setCount] = useState(0);
const [name, setName] = useState("");
const [user, setUser] = useState<User | null>(null);
Functional Updates
When new state depends on old state, pass a function to the setter. This avoids stale closure bugs, especially in event handlers and effects:
// Potentially stale — captures `count` from this render's closure
setCount(count + 1);
// Safe — always uses the latest value
setCount((prev) => prev + 1);
Lazy Initialisation
If initial state is expensive to compute (parsing, filtering), pass a function — it runs once, not on every render:
// Runs on every render — wasteful
const [state, setState] = useState(parseQueryString(window.location.search));
// Runs once — correct
const [state, setState] = useState(() =>
parseQueryString(window.location.search),
);
useEffect
useEffect runs code after the DOM has been painted — for subscriptions, timers, manual DOM manipulation, and data fetching.
useEffect(() => {
// runs after every render
});
useEffect(() => {
// runs once (on mount)
}, []);
useEffect(() => {
// runs when userId changes
}, [userId]);
Cleanup
Return a function to run on unmount (or before the effect re-runs):
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1000);
return () => clearInterval(id); // cleanup on unmount
}, []);
useEffect(() => {
const subscription = eventBus.on("message", handleMessage);
return () => subscription.unsubscribe();
}, [handleMessage]);
Data Fetching Pattern
React 18+ Strict Mode runs effects twice in development to expose missing cleanup. Write fetch effects with a cancellation flag:
useEffect(() => {
let cancelled = false;
async function load() {
try {
const data = await fetchUser(userId);
if (!cancelled) setUser(data);
} catch (err) {
if (!cancelled) setError(err);
}
}
load();
return () => {
cancelled = true;
};
}, [userId]);
For server-rendered apps with Astro, prefer fetching data in the .astro component script and passing it into your React islands as props — useEffect fetching ships extra JavaScript and adds waterfall latency.
useRef
useRef returns a mutable object whose .current property persists across renders without triggering re-renders. Two use cases:
DOM References
function SearchInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} type="search" />;
}
Mutable Values Without Re-renders
Store values you need to persist across renders but that shouldn’t trigger re-renders when they change:
function Timer() {
const intervalRef = useRef<ReturnType<typeof setInterval>>();
const [elapsed, setElapsed] = useState(0);
function start() {
intervalRef.current = setInterval(() => setElapsed((t) => t + 1), 1000);
}
function stop() {
clearInterval(intervalRef.current);
}
return (
<div>
<p>{elapsed}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
useCallback and useMemo
These hooks memoize values. Use them to prevent unnecessary re-renders of child components and expensive recomputations — but only when you have measured a real problem.
useCallback
Memoizes a function reference. Without it, every render creates a new function object, which breaks reference equality for child props:
// Without useCallback — onSubmit is a new function every render,
// causing <Form> to re-render even if nothing changed.
function Parent() {
const onSubmit = (data: FormData) => handleSubmit(data);
return <Form onSubmit={onSubmit} />;
}
// With useCallback — same function reference if dependencies haven't changed
function Parent() {
const onSubmit = useCallback(
(data: FormData) => handleSubmit(data),
[handleSubmit],
);
return <Form onSubmit={onSubmit} />;
}
Pair with React.memo on the child to make the optimisation work:
const Form = React.memo(function Form({
onSubmit,
}: {
onSubmit: (d: FormData) => void;
}) {
// only re-renders if onSubmit reference changed
});
useMemo
Memoizes a computed value:
function FilteredList({ items, filter }: { items: Item[]; filter: string }) {
// Without useMemo: re-filters on every render, even unrelated state changes
const filtered = useMemo(
() => items.filter((item) => item.name.includes(filter)),
[items, filter],
);
return (
<ul>
{filtered.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
When NOT to Memoize
Premature memoisation adds code complexity for no benefit. Skip it when:
- The computation is trivially fast (string formatting, simple math).
- The component renders infrequently.
- Dependencies change on every render anyway, defeating the cache.
Profile first. Memoize second.
useReducer
When state transitions are complex — multiple sub-values, transitions that depend on previous state, actions that change several fields at once — useReducer scales better than multiple useState calls.
type State = {
status: "idle" | "loading" | "success" | "error";
data: User | null;
error: string | null;
};
type Action =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; payload: User }
| { type: "FETCH_ERROR"; payload: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "FETCH_START":
return { status: "loading", data: null, error: null };
case "FETCH_SUCCESS":
return { status: "success", data: action.payload, error: null };
case "FETCH_ERROR":
return { status: "error", data: null, error: action.payload };
default:
return state;
}
}
function UserProfile({ userId }: { userId: string }) {
const [state, dispatch] = useReducer(reducer, {
status: "idle",
data: null,
error: null,
});
useEffect(() => {
dispatch({ type: "FETCH_START" });
fetchUser(userId)
.then((user) => dispatch({ type: "FETCH_SUCCESS", payload: user }))
.catch((err) => dispatch({ type: "FETCH_ERROR", payload: err.message }));
}, [userId]);
if (state.status === "loading") return <Spinner />;
if (state.status === "error") return <ErrorMessage message={state.error!} />;
if (!state.data) return null;
return <UserCard user={state.data} />;
}
useContext
Share state across a subtree without prop-drilling. Create a context, provide it high up, consume it anywhere below:
type ThemeContextValue = {
theme: "light" | "dark";
toggle: () => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggle = useCallback(
() => setTheme((t) => (t === "light" ? "dark" : "light")),
[],
);
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
return ctx;
}
// Any component in the tree
function Header() {
const { theme, toggle } = useTheme();
return (
<button onClick={toggle}>
Switch to {theme === "light" ? "dark" : "light"}
</button>
);
}
Custom Hooks
Custom hooks are the main composability mechanism — they let you extract and share stateful logic without changing component structure.
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
function useLocalStorage<T>(key: string, initialValue: T) {
const [stored, setStored] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStored((prev) => {
const next = value instanceof Function ? value(prev) : value;
window.localStorage.setItem(key, JSON.stringify(next));
return next;
});
},
[key],
);
return [stored, setValue] as const;
}
Conventions:
- Name starts with
use. - May call other hooks.
- Returns whatever the caller needs — a tuple, an object, or a single value.
- Does not render JSX.

useLayoutEffect
Runs synchronously after DOM mutations, before the browser paints. Use when you need to measure the DOM before the user sees it:
function Tooltip({
text,
anchorRef,
}: {
text: string;
anchorRef: RefObject<HTMLElement>;
}) {
const tooltipRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!anchorRef.current || !tooltipRef.current) return;
const { top, left, width } = anchorRef.current.getBoundingClientRect();
tooltipRef.current.style.top = `${top - 40}px`;
tooltipRef.current.style.left = `${left + width / 2}px`;
});
return (
<div ref={tooltipRef} className="tooltip">
{text}
</div>
);
}
Prefer useEffect by default; use useLayoutEffect only when the visual flicker from useEffect is visible.
Closing Thoughts
Hooks reward the mental model of “how should this behave” over “where should this live.” useState for local values, useReducer for complex transitions, useEffect for synchronising with the outside world, useContext for widely shared state, and custom hooks for everything that should be reusable. Master the dependency array and the cleanup pattern — those two ideas underpin 90% of bugs in hooks-based code.
About the author
The Plake Team
Writes long-form essays on craft, software, and the slow web. Curates the Sunday digest read by 88,000+ readers.
Read moreKeep reading
Related Articles









