React Hooks Essentials
React Hooks Essentials
Hooks are special functions that let you "hook into" React features from functional components. They're the mechanism behind state management, side effects, and most of the dynamic behavior in modern React apps.
The Rules of Hooks
Before diving in, there are two rules you need to know:
- Only call hooks at the top level — not inside loops, conditions, or nested functions
- Only call hooks in React components or custom hooks — not in regular functions
// ✅ Correct — hooks at the top level function MyComponent() { const [count, setCount] = useState(0); const [name, setName] = useState(""); return <div>{count}</div>; } // ❌ Wrong — hook inside a condition function MyComponent({ isLoggedIn }: { isLoggedIn: boolean }) { if (isLoggedIn) { const [user, setUser] = useState(null); // Don't do this! } return <div>...</div>; }
useState — Managing Data
You already learned useState in the previous tutorial. Here's a quick recap with additional patterns:
import { useState } from "react"; function Form() { // Simple state const [email, setEmail] = useState(""); // State with explicit type const [error, setError] = useState<string | null>(null); // State based on previous value (use callback form) const [count, setCount] = useState(0); const increment = () => setCount(prev => prev + 1); // Object state const [form, setForm] = useState({ name: "", email: "", message: "" }); const updateField = (field: string, value: string) => { setForm(prev => ({ ...prev, [field]: value })); }; }
Pro tip: When the new state depends on the old state, use the callback form: setState(prev => newValue). This avoids bugs with stale closures.
useEffect — Side Effects
useEffect runs code after your component renders. It's used for anything that interacts with the outside world — API calls, timers, event listeners, and more.
Basic Pattern
import { useState, useEffect } from "react"; function UserProfile({ userId }: { userId: string }) { const [user, setUser] = useState<User | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { // This code runs after the component renders const fetchUser = async () => { try { const response = await fetch(`/api/users/${userId}`); const data = await response.json(); setUser(data); } catch (error) { console.error("Failed to fetch user:", error); } finally { setLoading(false); } }; fetchUser(); }, [userId]); // <-- dependency array if (loading) return <p>Loading...</p>; if (!user) return <p>User not found</p>; return <h1>{user.name}</h1>; }
The Dependency Array
The second argument to useEffect controls when the effect runs:
// Runs after EVERY render useEffect(() => { console.log("Rendered!"); }); // Runs ONCE after the first render (like componentDidMount) useEffect(() => { console.log("Component mounted!"); }, []); // Runs when 'userId' or 'page' changes useEffect(() => { fetchData(userId, page); }, [userId, page]);
| Dependency Array | When It Runs |
|---|---|
| Not provided | Every render |
[] (empty) | Once on mount |
[a, b] | When a or b changes |
Cleanup Function
Some effects need to clean up after themselves — like removing event listeners or canceling timers:
useEffect(() => { // Set up const handleResize = () => { setWidth(window.innerWidth); }; window.addEventListener("resize", handleResize); // Clean up (runs before the effect re-runs or when component unmounts) return () => { window.removeEventListener("resize", handleResize); }; }, []);
The cleanup function returned from useEffect runs:
- Before the effect re-runs (if dependencies changed)
- When the component is removed from the page (unmounts)
What to ask your AI: "Create a useEffect that fetches data from [endpoint] when [dependency] changes. Include loading and error states."
useRef — Accessing DOM Elements and Persistent Values
useRef creates a mutable reference that persists across renders without causing re-renders when changed.
Accessing DOM Elements
import { useRef, useEffect } from "react"; function SearchInput() { const inputRef = useRef<HTMLInputElement>(null); useEffect(() => { // Focus the input when the component mounts inputRef.current?.focus(); }, []); return <input ref={inputRef} placeholder="Search..." />; }
Storing Mutable Values
function Timer() { const [seconds, setSeconds] = useState(0); const intervalRef = useRef<NodeJS.Timeout | null>(null); const start = () => { intervalRef.current = setInterval(() => { setSeconds(prev => prev + 1); }, 1000); }; const stop = () => { if (intervalRef.current) { clearInterval(intervalRef.current); } }; // Clean up on unmount useEffect(() => { return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; }, []); return ( <div> <p>{seconds} seconds</p> <button onClick={start}>Start</button> <button onClick={stop}>Stop</button> </div> ); }
Key difference from state: changing a ref does not cause a re-render.
Custom Hooks — Reusable Logic
Custom hooks let you extract component logic into reusable functions. They're regular functions that use hooks inside:
useFetch — A Custom Data Fetching Hook
function useFetch<T>(url: string) { const [data, setData] = useState<T | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { const fetchData = async () => { try { setLoading(true); const response = await fetch(url); if (!response.ok) throw new Error("Failed to fetch"); const result = await response.json(); setData(result); } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); } finally { setLoading(false); } }; fetchData(); }, [url]); return { data, loading, error }; } // Usage — clean and reusable! function UserList() { const { data: users, loading, error } = useFetch<User[]>("/api/users"); if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error}</p>; return ( <ul> {users?.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> ); }
useLocalStorage — Persistent State
function useLocalStorage<T>(key: string, initialValue: T) { const [value, setValue] = useState<T>(() => { try { const stored = localStorage.getItem(key); return stored ? JSON.parse(stored) : initialValue; } catch { return initialValue; } }); useEffect(() => { localStorage.setItem(key, JSON.stringify(value)); }, [key, value]); return [value, setValue] as const; } // Usage function Settings() { const [theme, setTheme] = useLocalStorage("theme", "light"); return ( <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}> Current: {theme} </button> ); }
useDebounce — Delayed Updates
function useDebounce<T>(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; } // Usage — search only fires after user stops typing function SearchBar() { const [query, setQuery] = useState(""); const debouncedQuery = useDebounce(query, 300); useEffect(() => { if (debouncedQuery) { // Fetch search results fetch(`/api/search?q=${debouncedQuery}`); } }, [debouncedQuery]); return ( <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." /> ); }
What to ask your AI: "Create a custom hook called use[Name] that [does this]. It should return [these values]."
Common Patterns Summary
| Hook | Purpose | When to Use |
|---|---|---|
useState | Manage component data | Any data that changes and affects UI |
useEffect | Side effects | API calls, subscriptions, DOM updates |
useRef | DOM access / mutable values | Focus inputs, store intervals, previous values |
| Custom hooks | Reusable logic | Extract repeated patterns across components |
What's Next?
You now have a solid foundation in React's core hooks. The next tutorial covers Next.js Routing and Pages — how to build multi-page applications with file-based routing.
What to ask your AI: "I'm building a [feature] and I need to [track/fetch/listen to] something. Which hook should I use and how?"