React Hooks
Hooks are functions that let you "hook into" React state and lifecycle features from function components. Introduced in React 16.8, hooks eliminated the need for class components in most cases. They let you reuse stateful logic between components without changing your component hierarchy.
Hooks follow two fundamental rules: only call hooks at the top level (never inside loops, conditions, or nested functions), and only call hooks from React function components or custom hooks. These rules ensure hooks work correctly across re-renders.
| 1 | // The Rules of Hooks |
| 2 | // 1. Only call hooks at the top level |
| 3 | // 2. Only call hooks from React functions |
| 4 | |
| 5 | // ✅ Correct |
| 6 | function MyComponent() { |
| 7 | const [count, setCount] = useState(0); // top level |
| 8 | useEffect(() => {}, []); // top level |
| 9 | return <div>{count}</div>; |
| 10 | } |
| 11 | |
| 12 | // ❌ Wrong — hook inside condition |
| 13 | function BadComponent({ show }) { |
| 14 | if (show) { |
| 15 | const [value, setValue] = useState(0); // conditional! |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | // ❌ Wrong — hook inside loop |
| 20 | function BadListComponent({ items }) { |
| 21 | items.forEach(item => { |
| 22 | const [selected, setSelected] = useState(false); // in loop! |
| 23 | }); |
| 24 | } |
useState declares a state variable. It returns a pair: the current state value and a function to update it. The state persists across re-renders. When the setter is called, React re-renders the component with the new value.
| 1 | import { useState } from "react"; |
| 2 | |
| 3 | function Counter() { |
| 4 | const [count, setCount] = useState(0); |
| 5 | |
| 6 | return ( |
| 7 | <div> |
| 8 | <p>Count: {count}</p> |
| 9 | <button onClick={() => setCount(count + 1)}>Increment</button> |
| 10 | <button onClick={() => setCount(prev => prev - 1)}>Decrement</button> |
| 11 | <button onClick={() => setCount(0)}>Reset</button> |
| 12 | </div> |
| 13 | ); |
| 14 | } |
For complex state updates, always use the functional updater form to avoid stale closures:
| 1 | function TodoApp() { |
| 2 | const [todos, setTodos] = useState([]); |
| 3 | |
| 4 | // ✅ Functional updater — uses latest state |
| 5 | const addTodo = (text) => { |
| 6 | setTodos(prev => [...prev, { id: Date.now(), text, done: false }]); |
| 7 | }; |
| 8 | |
| 9 | const toggleTodo = (id) => { |
| 10 | setTodos(prev => |
| 11 | prev.map(todo => |
| 12 | todo.id === id ? { ...todo, done: !todo.done } : todo |
| 13 | ) |
| 14 | ); |
| 15 | }; |
| 16 | |
| 17 | const removeTodo = (id) => { |
| 18 | setTodos(prev => prev.filter(todo => todo.id !== id)); |
| 19 | }; |
| 20 | |
| 21 | // ❌ Direct state reference — may be stale in async |
| 22 | const addTodoBad = (text) => { |
| 23 | setTodos([...todos, { id: Date.now(), text }]); // stale todos! |
| 24 | }; |
| 25 | |
| 26 | return ( |
| 27 | <ul> |
| 28 | {todos.map(todo => ( |
| 29 | <li key={todo.id} onClick={() => toggleTodo(todo.id)}> |
| 30 | {todo.done ? "✓" : "○"} {todo.text} |
| 31 | </li> |
| 32 | ))} |
| 33 | </ul> |
| 34 | ); |
| 35 | } |
info
useEffect lets you perform side effects in function components: data fetching, subscriptions, DOM manipulation, timers, and more. It runs after render and can return a cleanup function.
| 1 | import { useState, useEffect } from "react"; |
| 2 | |
| 3 | function UserProfile({ userId }) { |
| 4 | const [user, setUser] = useState(null); |
| 5 | const [loading, setLoading] = useState(true); |
| 6 | |
| 7 | useEffect(() => { |
| 8 | let cancelled = false; |
| 9 | |
| 10 | async function fetchUser() { |
| 11 | setLoading(true); |
| 12 | try { |
| 13 | const res = await fetch(`/api/users/${userId}`); |
| 14 | const data = await res.json(); |
| 15 | if (!cancelled) setUser(data); |
| 16 | } catch (err) { |
| 17 | console.error("Failed to fetch user:", err); |
| 18 | } finally { |
| 19 | if (!cancelled) setLoading(false); |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | fetchUser(); |
| 24 | |
| 25 | // Cleanup function — runs before next effect or unmount |
| 26 | return () => { cancelled = true; }; |
| 27 | }, [userId]); // Re-run when userId changes |
| 28 | |
| 29 | if (loading) return <p>Loading...</p>; |
| 30 | if (!user) return <p>User not found</p>; |
| 31 | |
| 32 | return ( |
| 33 | <div> |
| 34 | <h2>{user.name}</h2> |
| 35 | <p>{user.email}</p> |
| 36 | </div> |
| 37 | ); |
| 38 | } |
Common useEffect patterns and their dependency arrays:
| 1 | // Run on every render (no dependency array) |
| 2 | useEffect(() => { |
| 3 | console.log("Rendered"); |
| 4 | }); |
| 5 | |
| 6 | // Run once on mount |
| 7 | useEffect(() => { |
| 8 | console.log("Mounted"); |
| 9 | return () => console.log("Unmounted"); |
| 10 | }, []); |
| 11 | |
| 12 | // Run when specific values change |
| 13 | useEffect(() => { |
| 14 | document.title = `Count: ${count}`; |
| 15 | }, [count]); |
| 16 | |
| 17 | // Run when userId changes (with cleanup) |
| 18 | useEffect(() => { |
| 19 | const subscription = subscribe(userId); |
| 20 | return () => subscription.unsubscribe(); |
| 21 | }, [userId]); |
| 22 | |
| 23 | // Subscribing to browser events |
| 24 | useEffect(() => { |
| 25 | const handler = (e) => console.log("Key:", e.key); |
| 26 | window.addEventListener("keydown", handler); |
| 27 | return () => window.removeEventListener("keydown", handler); |
| 28 | }, []); |
warning
useReducer is an alternative to useState for complex state logic. It uses a reducer function (similar to Redux) where actions describe what happened and the reducer computes the next state.
| 1 | import { useReducer } from "react"; |
| 2 | |
| 3 | const initialState = { items: [], total: 0 }; |
| 4 | |
| 5 | function cartReducer(state, action) { |
| 6 | switch (action.type) { |
| 7 | case "ADD_ITEM": { |
| 8 | const existing = state.items.find(i => i.id === action.item.id); |
| 9 | if (existing) { |
| 10 | return { |
| 11 | ...state, |
| 12 | items: state.items.map(i => |
| 13 | i.id === action.item.id ? { ...i, quantity: i.quantity + 1 } : i |
| 14 | ), |
| 15 | total: state.total + action.item.price, |
| 16 | }; |
| 17 | } |
| 18 | return { |
| 19 | ...state, |
| 20 | items: [...state.items, { ...action.item, quantity: 1 }], |
| 21 | total: state.total + action.item.price, |
| 22 | }; |
| 23 | } |
| 24 | case "REMOVE_ITEM": { |
| 25 | const item = state.items.find(i => i.id === action.id); |
| 26 | return { |
| 27 | ...state, |
| 28 | items: state.items.filter(i => i.id !== action.id), |
| 29 | total: state.total - (item?.price * item?.quantity || 0), |
| 30 | }; |
| 31 | } |
| 32 | case "CLEAR_CART": |
| 33 | return initialState; |
| 34 | default: |
| 35 | return state; |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | function ShoppingCart() { |
| 40 | const [state, dispatch] = useReducer(cartReducer, initialState); |
| 41 | |
| 42 | return ( |
| 43 | <div> |
| 44 | <h2>Cart ({state.items.length} items)</h2> |
| 45 | {state.items.map(item => ( |
| 46 | <div key={item.id}> |
| 47 | {item.name} x{item.quantity} |
| 48 | <button onClick={() => dispatch({ type: "REMOVE_ITEM", id: item.id })}> |
| 49 | Remove |
| 50 | </button> |
| 51 | </div> |
| 52 | ))} |
| 53 | <p>Total: ${state.total.toFixed(2)}</p> |
| 54 | <button onClick={() => dispatch({ type: "CLEAR_CART" })}>Clear Cart</button> |
| 55 | </div> |
| 56 | ); |
| 57 | } |
best practice
useContext reads and subscribes to a React Context. It accepts a context object (created by React.createContext) and returns the current context value. The component re-renders when the provider value changes.
| 1 | import { createContext, useContext, useState } from "react"; |
| 2 | |
| 3 | const ThemeContext = createContext("light"); |
| 4 | |
| 5 | function ThemeProvider({ children }) { |
| 6 | const [theme, setTheme] = useState("light"); |
| 7 | |
| 8 | const toggleTheme = () => { |
| 9 | setTheme(prev => prev === "light" ? "dark" : "light"); |
| 10 | }; |
| 11 | |
| 12 | return ( |
| 13 | <ThemeContext.Provider value={{ theme, toggleTheme }}> |
| 14 | {children} |
| 15 | </ThemeContext.Provider> |
| 16 | ); |
| 17 | } |
| 18 | |
| 19 | function ThemedButton() { |
| 20 | const { theme, toggleTheme } = useContext(ThemeContext); |
| 21 | |
| 22 | return ( |
| 23 | <button |
| 24 | onClick={toggleTheme} |
| 25 | className={theme === "dark" ? "bg-gray-800 text-white" : "bg-white text-gray-800"} |
| 26 | > |
| 27 | Current theme: {theme}. Click to toggle. |
| 28 | </button> |
| 29 | ); |
| 30 | } |
| 31 | |
| 32 | function App() { |
| 33 | return ( |
| 34 | <ThemeProvider> |
| 35 | <ThemedButton /> |
| 36 | <ThemedButton /> {/* Both stay in sync via context */} |
| 37 | </ThemeProvider> |
| 38 | ); |
| 39 | } |
warning
useRef creates a mutable ref object whose .currentproperty persists across re-renders without causing re-renders when it changes. It's used for DOM references, storing previous values, and holding mutable values that don't trigger re-renders.
| 1 | import { useRef, useState, useEffect } from "react"; |
| 2 | |
| 3 | function TextInput() { |
| 4 | const inputRef = useRef(null); |
| 5 | |
| 6 | const focusInput = () => { |
| 7 | inputRef.current?.focus(); |
| 8 | }; |
| 9 | |
| 10 | return ( |
| 11 | <div> |
| 12 | <input ref={inputRef} type="text" placeholder="Type here..." /> |
| 13 | <button onClick={focusInput}>Focus Input</button> |
| 14 | </div> |
| 15 | ); |
| 16 | } |
| 17 | |
| 18 | // Storing previous value |
| 19 | function usePrevious(value) { |
| 20 | const ref = useRef(); |
| 21 | |
| 22 | useEffect(() => { |
| 23 | ref.current = value; |
| 24 | }, [value]); |
| 25 | |
| 26 | return ref.current; |
| 27 | } |
| 28 | |
| 29 | function Counter() { |
| 30 | const [count, setCount] = useState(0); |
| 31 | const prevCount = usePrevious(count); |
| 32 | |
| 33 | return ( |
| 34 | <div> |
| 35 | <p>Now: {count}, Before: {prevCount}</p> |
| 36 | <button onClick={() => setCount(c => c + 1)}>Increment</button> |
| 37 | </div> |
| 38 | ); |
| 39 | } |
| 40 | |
| 41 | // Storing interval ID without re-rendering |
| 42 | function Timer() { |
| 43 | const [seconds, setSeconds] = useState(0); |
| 44 | const intervalRef = useRef(null); |
| 45 | |
| 46 | const start = () => { |
| 47 | intervalRef.current = setInterval(() => { |
| 48 | setSeconds(s => s + 1); |
| 49 | }, 1000); |
| 50 | }; |
| 51 | |
| 52 | const stop = () => { |
| 53 | clearInterval(intervalRef.current); |
| 54 | }; |
| 55 | |
| 56 | return ( |
| 57 | <div> |
| 58 | <p>Seconds: {seconds}</p> |
| 59 | <button onClick={start}>Start</button> |
| 60 | <button onClick={stop}>Stop</button> |
| 61 | </div> |
| 62 | ); |
| 63 | } |
useMemo memoizes the result of an expensive computation. It only recalculates when one of its dependencies changes. Use it for expensive calculations, filtering/sorting large lists, and creating stable object references.
| 1 | import { useMemo, useState } from "react"; |
| 2 | |
| 3 | function ProductList({ products, filter, sortBy }) { |
| 4 | const [search, setSearch] = useState(""); |
| 5 | |
| 6 | // Expensive computation — only recalculates when inputs change |
| 7 | const filteredProducts = useMemo(() => { |
| 8 | console.log("Recomputing filtered products..."); |
| 9 | return products |
| 10 | .filter(p => p.category === filter) |
| 11 | .filter(p => p.name.toLowerCase().includes(search.toLowerCase())) |
| 12 | .sort((a, b) => { |
| 13 | if (sortBy === "price") return a.price - b.price; |
| 14 | if (sortBy === "name") return a.name.localeCompare(b.name); |
| 15 | return 0; |
| 16 | }); |
| 17 | }, [products, filter, search, sortBy]); |
| 18 | |
| 19 | return ( |
| 20 | <div> |
| 21 | <input |
| 22 | value={search} |
| 23 | onChange={e => setSearch(e.target.value)} |
| 24 | placeholder="Search products..." |
| 25 | /> |
| 26 | <p>{filteredProducts.length} products found</p> |
| 27 | {filteredProducts.map(product => ( |
| 28 | <ProductCard key={product.id} product={product} /> |
| 29 | ))} |
| 30 | </div> |
| 31 | ); |
| 32 | } |
info
useCallback returns a memoized version of a callback function. It only changes when its dependencies change. This prevents unnecessary re-renders of child components that receive the function as a prop.
| 1 | import { useState, useCallback, memo } from "react"; |
| 2 | |
| 3 | // Without useCallback — new function reference every render |
| 4 | function ParentUnoptimized() { |
| 5 | const [count, setCount] = useState(0); |
| 6 | |
| 7 | const handleClick = () => console.log("clicked"); |
| 8 | // handleClick is a new reference every render |
| 9 | |
| 10 | return ( |
| 11 | <div> |
| 12 | <button onClick={() => setCount(c => c + 1)}>Count: {count}</button> |
| 13 | <ExpensiveChild onClick={handleClick} /> {/* re-renders every time! */} |
| 14 | </div> |
| 15 | ); |
| 16 | } |
| 17 | |
| 18 | // With useCallback — stable function reference |
| 19 | function ParentOptimized() { |
| 20 | const [count, setCount] = useState(0); |
| 21 | |
| 22 | const handleClick = useCallback(() => { |
| 23 | console.log("clicked"); |
| 24 | }, []); // No dependencies, never changes |
| 25 | |
| 26 | return ( |
| 27 | <div> |
| 28 | <button onClick={() => setCount(c => c + 1)}>Count: {count}</button> |
| 29 | <ExpensiveChild onClick={handleClick} /> {/* only re-renders once */} |
| 30 | </div> |
| 31 | ); |
| 32 | } |
| 33 | |
| 34 | const ExpensiveChild = memo(function ExpensiveChild({ onClick }) { |
| 35 | console.log("ExpensiveChild rendered"); |
| 36 | return <button onClick={onClick}>Click me</button>; |
| 37 | }); |
best practice
Custom hooks are functions that start with "use" and can call other hooks. They let you extract component logic into reusable functions. Custom hooks share stateful logic, not state itself — each call gets its own state.
| 1 | import { useState, useEffect, useCallback } from "react"; |
| 2 | |
| 3 | // Custom hook: useFetch |
| 4 | function useFetch(url, options = {}) { |
| 5 | const [data, setData] = useState(null); |
| 6 | const [error, setError] = useState(null); |
| 7 | const [loading, setLoading] = useState(true); |
| 8 | |
| 9 | useEffect(() => { |
| 10 | const controller = new AbortController(); |
| 11 | |
| 12 | async function fetchData() { |
| 13 | setLoading(true); |
| 14 | try { |
| 15 | const res = await fetch(url, { ...options, signal: controller.signal }); |
| 16 | if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| 17 | const json = await res.json(); |
| 18 | setData(json); |
| 19 | } catch (err) { |
| 20 | if (err.name !== "AbortError") setError(err); |
| 21 | } finally { |
| 22 | setLoading(false); |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | fetchData(); |
| 27 | return () => controller.abort(); |
| 28 | }, [url]); |
| 29 | |
| 30 | return { data, error, loading }; |
| 31 | } |
| 32 | |
| 33 | // Custom hook: useLocalStorage |
| 34 | function useLocalStorage(key, initialValue) { |
| 35 | const [value, setValue] = useState(() => { |
| 36 | try { |
| 37 | const stored = localStorage.getItem(key); |
| 38 | return stored ? JSON.parse(stored) : initialValue; |
| 39 | } catch { |
| 40 | return initialValue; |
| 41 | } |
| 42 | }); |
| 43 | |
| 44 | useEffect(() => { |
| 45 | localStorage.setItem(key, JSON.stringify(value)); |
| 46 | }, [key, value]); |
| 47 | |
| 48 | return [value, setValue]; |
| 49 | } |
| 50 | |
| 51 | // Custom hook: useDebounce |
| 52 | function useDebounce(value, delay = 300) { |
| 53 | const [debouncedValue, setDebouncedValue] = useState(value); |
| 54 | |
| 55 | useEffect(() => { |
| 56 | const timer = setTimeout(() => setDebouncedValue(value), delay); |
| 57 | return () => clearTimeout(timer); |
| 58 | }, [value, delay]); |
| 59 | |
| 60 | return debouncedValue; |
| 61 | } |
| 62 | |
| 63 | // Using custom hooks together |
| 64 | function SearchApp() { |
| 65 | const [query, setQuery] = useLocalStorage("search-query", ""); |
| 66 | const debouncedQuery = useDebounce(query, 500); |
| 67 | const { data, loading, error } = useFetch( |
| 68 | debouncedQuery ? `/api/search?q=${debouncedQuery}` : null |
| 69 | ); |
| 70 | |
| 71 | return ( |
| 72 | <div> |
| 73 | <input value={query} onChange={e => setQuery(e.target.value)} /> |
| 74 | {loading && <p>Searching...</p>} |
| 75 | {error && <p>Error: {error.message}</p>} |
| 76 | {data && <SearchResults results={data} />} |
| 77 | </div> |
| 78 | ); |
| 79 | } |
pro tip
React provides additional hooks for specific use cases:
| 1 | import { useId, useTransition, useDeferredValue, useLayoutEffect, |
| 2 | useImperativeHandle, forwardRef } from "react"; |
| 3 | |
| 4 | // useId — stable unique IDs for accessibility |
| 5 | function FormField({ label }) { |
| 6 | const id = useId(); |
| 7 | return ( |
| 8 | <div> |
| 9 | <label htmlFor={id}>{label}</label> |
| 10 | <input id={id} type="text" /> |
| 11 | </div> |
| 12 | ); |
| 13 | } |
| 14 | |
| 15 | // useTransition — mark non-urgent updates |
| 16 | function TabContainer() { |
| 17 | const [isPending, startTransition] = useTransition(); |
| 18 | const [tab, setTab] = useState("home"); |
| 19 | |
| 20 | const selectTab = (nextTab) => { |
| 21 | startTransition(() => { |
| 22 | setTab(nextTab); |
| 23 | }); |
| 24 | }; |
| 25 | |
| 26 | return ( |
| 27 | <div> |
| 28 | <TabButtons onSelect={selectTab} /> |
| 29 | {isPending && <Spinner />} |
| 30 | <TabContent activeTab={tab} /> |
| 31 | </div> |
| 32 | ); |
| 33 | } |
| 34 | |
| 35 | // useDeferredValue — defer rendering of non-urgent content |
| 36 | function SearchResults({ query }) { |
| 37 | const deferredQuery = useDeferredValue(query); |
| 38 | return <ResultsList query={deferredQuery} />; |
| 39 | } |
| 40 | |
| 41 | // useImperativeHandle — customize ref handle |
| 42 | const FancyInput = forwardRef(function FancyInput(props, ref) { |
| 43 | const inputRef = useRef(null); |
| 44 | |
| 45 | useImperativeHandle(ref, () => ({ |
| 46 | focus: () => inputRef.current?.focus(), |
| 47 | clear: () => { inputRef.current.value = ""; }, |
| 48 | })); |
| 49 | |
| 50 | return <input ref={inputRef} {...props} />; |
| 51 | }); |
note