React Hooks Guide: useState, useEffect, useRef, and Custom Hooks
Hooks are functions that let you “hook into” React state and lifecycle features from function components. Introduced in React 16.8, hooks represent the single biggest API change in React’s history. They eliminate the need for class components, reduce the complexity of sharing stateful logic, and make components more readable. As Sophie Alpert, former React engineering manager, stated: “Hooks are the future of React.”
The Rules of Hooks
Before diving into individual hooks, it is critical to understand the Rules of Hooks, which are enforced by the eslint-plugin-react-hooks plugin. These rules exist because hooks rely on a stable call order between renders. Without them, React cannot correctly associate state with the right component instance.
- Only call hooks at the top level. Do not call hooks inside loops, conditions, or nested functions.
- Only call hooks from React functions. Call them from React function components or custom hooks, not from regular JavaScript functions.
Violating these rules leads to unpredictable behavior because React uses the order of hook calls to maintain state across renders. The React documentation’s “Hooks at a Glance” page emphasizes that these rules are not arbitrary — they are fundamental to how hooks work internally.
useState: Local Component State
useState is the most basic hook. It declares a state variable that persists between renders and provides a setter function to update it:
const [count, setCount] = useState(0);The initial value is used only on the first render. On subsequent renders, React returns the current state value. The setter function can accept either a new value or a function that receives the previous state:
setCount(prevCount => prevCount + 1);Functional updates are important when the new state depends on the previous state, especially in concurrent mode where React may batch updates. React 18’s automatic batching, described in the React 18 release notes, means multiple state updates in event handlers are batched into a single re-render. The functional update form guarantees correctness regardless of batching behavior.
Lazy Initial State
If the initial state is expensive to compute, pass a function instead of a value:
const [state, setState] = useState(() => computeExpensiveInitialValue());This function runs only during the initial render, not on subsequent re-renders.
useEffect: Side Effects in Components
useEffect lets you perform side effects: data fetching, subscriptions, DOM manipulation, and logging. It runs after the browser paints, so it does not block visual updates.
useEffect(() => {
document.title = `You clicked ${count} times`;
---, [count]);The second argument is the dependency array. React compares each dependency with its previous value using Object.is. When dependencies change, the effect re-runs. An empty array [] means the effect runs only once (on mount). Omitting the array entirely means the effect runs after every render, which is almost always an anti-pattern.
Cleanup Function
Effects that create subscriptions, timers, or event listeners should return a cleanup function:
useEffect(() => {
const subscription = dataSource.subscribe();
return () => subscription.unsubscribe();
---, []);The cleanup runs before the component unmounts and before every re-run of the effect.
Common Mistakes with useEffect
Dan Abramov’s article “A Complete Guide to useEffect” is the definitive resource on this hook. Key insights include:
- Dependencies should include every value from the component scope that the effect reads
- Omitting required dependencies can cause stale closures
useEffectis not a lifecycle hook — it synchronizes your component with external systems- Effects should be separated by concern, not by lifecycle phase
useLayoutEffect
useLayoutEffect runs synchronously after all DOM mutations but before the browser paints. Use it for reading layout and synchronously re-rendering (e.g., measuring DOM elements for animations). The React documentation warns that useLayoutEffect should be avoided when possible because it blocks visual updates.
useContext: Consuming React Context
useContext subscribes to a React context and returns the current context value:
const theme = useContext(ThemeContext);When the nearest <ThemeContext.Provider> updates, the component re-renders with the new value. Context is not a state management tool — it is a dependency injection mechanism. Overusing context can cause performance issues because every consumer re-renders when the context value changes. The React documentation recommends splitting contexts that change at different rates.
useRef: Mutable References
useRef returns a mutable ref object whose .current property persists across renders. Unlike state, changing a ref does not cause a re-render:
const inputRef = useRef(null);
return <input ref={inputRef} />;Refs are useful for accessing DOM elements directly, storing previous values, and holding mutable values that should not trigger re-renders. The React documentation also shows using refs for storing timer IDs and subscription handles.
function usePrevious(value) {
const ref = useRef();
useEffect(() => { ref.current = value; });
return ref.current;
---useMemo and useCallback: Performance Optimizations
useMemo memoizes the result of an expensive computation:
const sortedList = useMemo(() => list.sort(), [list]);useCallback memoizes a function definition:
const handleClick = useCallback(() => setCount(c => c + 1), []);The React documentation cautions that useMemo and useCallback should not be used prematurely. They add memory overhead and comparison logic. Profile first with React DevTools, then optimize where actual bottlenecks exist.
useReducer: Complex State Logic
useReducer is preferable to useState when state logic involves multiple sub-values or depends on complex transitions. It follows the same reducer pattern as Redux:
const initialState = { count: 0, step: 1 };
function reducer(state, action) {
switch (action.type) {
case 'increment': return { ...state, count: state.count + state.step };
case 'decrement': return { ...state, count: state.count - state.step };
case 'setStep': return { ...state, step: action.payload };
default: throw new Error('Unknown action');
}
---The dispatch function identity is stable across renders, which means it can be passed to child components without causing unnecessary re-renders. This makes useReducer a good choice when the state update logic is complex and needs to be tested independently.
useId for Accessibility
useId generates stable, unique IDs for accessibility attributes. It is particularly useful for connecting form inputs with labels via htmlFor and aria-describedby:
function TextField({ label }) {
const id = useId();
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} type="text" />
</>
);
---useId generates IDs that are consistent across server and client rendering, preventing hydration mismatches. The React documentation recommends useId over manually generating IDs with counters or random values.
useDeferredValue and useTransition
These hooks, introduced in React 18, help prioritize updates. useTransition marks a state update as low priority:
const [isPending, startTransition] = useTransition();
startTransition(() => setSearchQuery(value));useDeferredValue lets you defer re-rendering a portion of the UI:
const deferredQuery = useDeferredValue(query);Both hooks prevent blocking the main thread with expensive renders while keeping the UI responsive. The React 18 documentation explains that these hooks are part of the concurrent features that allow React to interrupt rendering to handle more urgent updates.
Custom Hooks: Reusing Logic
Custom hooks are the most powerful pattern enabled by hooks. They let you extract component logic into reusable functions:
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return width;
---Custom hooks can call other hooks, manage state, and return any value. The React documentation includes several custom hook examples: useFriendStatus, useOnlineStatus, and useDocumentTitle. Libraries like react-use and usehooks-ts provide dozens of production-ready custom hooks.
React Performance Best Practices
Performance optimization in React requires understanding when and why components re-render. React re-renders a component when its state changes, its parent re-renders, or the context it consumes changes. Unnecessary re-renders are the most common performance issue. Use React.memo to prevent re-renders when props have not changed — it performs a shallow comparison of props and skips rendering if they are identical. The useMemo hook memoizes expensive computation results, recalculating only when dependencies change. The useCallback hook memoizes function references, preventing child components from re-rendering due to new function references on every parent render. For lists, ensure each item has a stable, unique key prop — using array indices as keys causes bugs when items are reordered. Virtualization libraries like react-window and react-virtuoso render only visible items in long lists, dramatically reducing DOM nodes. Code splitting with React.lazy and Suspense loads components on demand, reducing initial bundle size. Profile your application with React DevTools Profiler before optimizing — measuring identifies actual bottlenecks that are worth fixing.
Testing React Components Effectively
React Testing Library encourages testing from the user’s perspective. Write tests that verify behavior, not implementation details. Use getByRole, getByLabelText, and getByText queries to find elements the way users do — by accessibility attributes and visible text. Avoid testing internal state or component internals; instead, test what the user sees and does. For async operations, use waitFor and findBy queries that retry until the element appears. Mock external dependencies at the network level using MSW (Mock Service Worker) rather than mocking imported modules. This creates more realistic tests that verify your integration with actual API contracts.
FAQ
Can I use hooks inside class components?
No. Hooks are only available in function components and custom hooks. There is no plan to backport hooks to class components. Migrate class components to functions when you want to use hooks.
Why do hooks need to be called in the same order every render?
React internally stores hook state in a linked list indexed by call order. If the order changes between renders (e.g., a hook inside a conditional), React cannot correctly map state to the right hook call.
What is the difference between useEffect and useLayoutEffect?
useEffect runs asynchronously after paint. useLayoutEffect runs synchronously before paint. Use useLayoutEffect only when you need to read DOM layout and prevent visual flicker.
How do I fetch data with hooks?
Use useEffect with useState and useReducer. For production applications, consider libraries like React Query or SWR that handle caching, deduplication, and stale-while-revalidate patterns built on hooks.
Conclusion
Hooks transformed React from a class-centric library into a function-first one. Understanding useState, useEffect, useRef, and custom hooks is essential for modern React development. The official React documentation’s “Hooks at a Glance” and “Rules of Hooks” pages are mandatory reading. For advanced patterns, explore the eslint-plugin-react-hooks rules and libraries like React Query that build sophisticated APIs on top of basic hooks. Next, read React Components for composition patterns and React State Management for handling complex application state.
For a comprehensive overview, read our article on Next Js Guide.
For a comprehensive overview, read our article on React Authentication.