You already know useState. React provides several other hooks that solve common problems: running side effects, accessing DOM elements, sharing data across the tree, and extracting reusable logic.
useEffect
useEffect runs a function after the component renders. It's how you perform side effects — data fetching, subscriptions, timers, or manual DOM manipulation.
import { useState, useEffect } from "react";
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <p>Loading...</p>;
if (!user) return <p>User not found.</p>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}The dependency array [userId] tells React to re-run the effect only when userId changes. Key rules:
- No array — runs after every render.
- Empty array
[]— runs once on mount. - With dependencies
[a, b]— runs whenaorbchange.
Cleanup
If your effect creates a subscription or timer, return a cleanup function:
import { useState, useEffect } from "react";
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(interval); // cleanup on unmount
}, []);
return <p>{time.toLocaleTimeString()}</p>;
}React calls the cleanup before the effect re-runs and when the component unmounts.
useRef
useRef gives you a mutable container that persists across renders without causing re-renders when changed. Its most common use is accessing DOM elements:
import { useRef, useEffect } from "react";
function AutoFocusInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} type="text" placeholder="I focus on mount" />;
}
You can also use useRef to store any mutable value that shouldn't trigger a re-render, such as a previous value or a timer ID:
import { useState, useRef, useEffect } from "react";
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
function PriceDisplay({ price }: { price: number }) {
const prevPrice = usePrevious(price);
const direction =
prevPrice !== undefined
? price > prevPrice
? "up"
: price < prevPrice
? "down"
: "same"
: "same";
return (
<span className={direction= "up" ? "text-green-600" : direction= "down" ? "text-red-600" : ""}>
${price.toFixed(2)} {direction === "up" ? "↑" : direction === "down" ? "↓" : ""}
</span>
);
}useContext
Context lets you pass data through the component tree without prop drilling. Create a context, wrap a portion of the tree with a provider, and consume it with useContext:
import { createContext, useContext, useState } from "react";
interface ThemeContext {
theme: "light" | "dark";
toggle: () => void;
}
const ThemeContext = createContext<ThemeContext | null>(null);
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
function toggle() {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
}
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
function ThemeToggle() {
const { theme, toggle } = useTheme();
return (
<button onClick={toggle}>
Current theme: {theme}. Click to switch.
</button>
);
}
// Usage in your app
function App() {
return (
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>
);
}Context is ideal for global concerns like themes, authentication, or locale. Avoid overusing it for every piece of state — it re-renders all consumers when the value changes.
Custom Hooks
A custom hook is a function that starts with use and calls other hooks. It lets you extract and reuse stateful logic:
import { useState, useEffect } from "react";
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
const stored = localStorage.getItem(key);
return stored ? (JSON.parse(stored) as T) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// Usage
function Settings() {
const [name, setName] = useLocalStorage("username", "");
return (
<input
value={name}
onChange={(e)=> setName(e.target.value)}
placeholder="Your name (persisted)"
/>
);
}
Another practical example — a hook that tracks window dimensions:
import { useState, useEffect } from "react";
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
handleResize(); // set initial size
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return size;
}
function ResponsiveLayout() {
const { width } = useWindowSize();
return <p>Window width: {width}px — {width < 768 ? "Mobile" : "Desktop"}</p>;
}Custom hooks follow the same rules as built-in hooks: call them at the top level, never inside conditions or loops.
Summary
useEffecthandles side effects and supports cleanup functions.useRefgives you a mutable container that doesn't cause re-renders — useful for DOM access and persistent values.useContextprovides tree-wide data sharing without prop drilling.- Custom hooks (
use*functions) let you extract and share stateful logic across components.