React Hooks, introduced in React 16.8, revolutionized how developers manage state and side effects in React applications. By allowing state and lifecycle features in functional components, hooks eliminated the need for complex class components and made code more readable and reusable. This guide covers every essential hook, advanced patterns, and practical strategies for managing state at scale.
useState: Managing Component State
The useState hook is the most fundamental hook for managing local component state. It returns a state variable and a setter function, replacing this.state and this.setState from class components.
Keep state as close to where it’s used as possible. If only one component needs a piece of state, keep it in that component rather than lifting it up unnecessarily.
Lazy Initialization
When the initial state requires an expensive computation, pass a function to useState instead of a value. This function runs only on the initial render, not on every re-render. For example, if you need to parse data from localStorage or compute a derived initial value, lazy initialization prevents unnecessary work on subsequent renders.
Functional Updates
When the new state depends on the previous state, use the functional update form: setState(prev => prev + 1). This is essential for correctness in scenarios where multiple state updates may be batched. Without functional updates, you risk using stale state values, especially inside event handlers, timeouts, or intervals.
Tip: Avoid storing derived values in state. If a value can be computed from existing state or props, calculate it during rendering instead. Storing derived data in state creates synchronization bugs where the derived value gets out of sync with its source.
useEffect: Handling Side Effects
useEffect is the Swiss Army knife of hooks. It handles side effects like data fetching, subscriptions, DOM manipulation, and timer management. The dependency array controls when the effect runs — an empty array means it runs once on mount, while specific dependencies trigger re-runs when those values change.
Always clean up subscriptions and timers in the cleanup function to prevent memory leaks.
Common Pitfalls with useEffect
- Missing dependencies — Omitting dependencies from the dependency array causes stale closures. The effect captures old values and does not update when the source data changes. Always include every value from the component scope that the effect uses.
- Object and array dependencies — Objects and arrays are compared by reference, not by value. Creating a new object or array on every render triggers the effect every time, even if the contents have not changed. Use useMemo to stabilize these references or restructure your dependencies to use primitive values.
- Infinite loops — Setting state inside useEffect without proper dependency management causes infinite re-render loops. The state change triggers a re-render, which triggers the effect, which sets state again. Always ensure your dependencies create a termination condition.
- Race conditions in data fetching — When fetching data based on user input, rapid changes can cause responses to arrive out of order. Use the cleanup function to set a cancelled flag and ignore stale responses, or use AbortController to cancel pending fetch requests.
useReducer: Complex State Logic
When component state involves multiple sub-values or when the next state depends on the previous one in complex ways, useReducer is a better choice than multiple useState calls. It follows the same pattern as Redux reducers: dispatch an action, and the reducer function computes the new state based on the current state and the action.
When to Use useReducer Over useState
- Your state has multiple related values that change together (e.g., a form with loading, error, and data states)
- State transitions follow defined business rules (e.g., a multi-step wizard where valid transitions are constrained)
- You want to centralize state update logic in one place for easier testing and debugging
- You need to pass a stable dispatch function to deeply nested child components (dispatch identity never changes)
A common and effective pattern is combining useReducer with useContext to create a lightweight state management solution for a subtree of your application, avoiding the need for external libraries in many cases.
useMemo and useCallback: Performance Optimization
useMemo memoizes expensive computations, recalculating only when dependencies change. useCallback memoizes function references, preventing unnecessary re-renders of child components that receive functions as props.
Don’t overuse these hooks — premature optimization adds complexity. Profile your application first and apply memoization where it actually makes a measurable difference.
When Memoization Actually Helps
Memoization provides real value in specific scenarios: computing derived data from large datasets (sorting, filtering, or transforming hundreds or thousands of items), stabilizing object or function references passed to components wrapped in React.memo, and preventing expensive child component re-renders when parent state changes that are unrelated to the child’s props.
Performance insight: The React team has indicated that a future version of React may automatically memoize components, making manual useMemo and useCallback less necessary. The React Compiler (previously known as React Forget) aims to handle these optimizations automatically. Until then, use profiling tools to identify actual bottlenecks before applying memoization.
useContext: Avoiding Prop Drilling
useContext provides a clean way to share state across deeply nested components without passing props through every level. Combined with useReducer, it can serve as a lightweight alternative to Redux for simpler applications.
Create focused contexts for different concerns (auth, theme, locale) rather than one massive global state object.
Context API Patterns
A well-structured context pattern involves creating a context, a provider component that encapsulates the state logic, and a custom hook that consumes the context. The custom hook should throw an error if used outside the provider, providing a clear error message for developers. This pattern — often called the “provider pattern” — keeps your context API clean and prevents misuse.
Context Performance Considerations
Every component that consumes a context re-renders whenever the context value changes. If your context provides an object, every state change in that object triggers re-renders in all consumers, even if they only use a portion of the state. Mitigate this by splitting large contexts into smaller, focused ones, memoizing context values with useMemo, or using a state management library for high-frequency updates.
Custom Hooks: Reusable Logic
Custom hooks are where the real power of hooks shines. Extract reusable logic into custom hooks like useForm, useFetch, useLocalStorage, or useDebounce. This promotes code reuse across components and keeps your components focused on rendering.
A well-designed custom hook encapsulates complexity and exposes a simple, intuitive API to consuming components.
Essential Custom Hook Patterns
- useLocalStorage — Synchronizes state with localStorage, handling serialization, deserialization, and cross-tab updates via the storage event. This is useful for persisting user preferences, form drafts, or UI state across sessions.
- useDebounce — Delays updating a value until a specified time has passed since the last change. Essential for search inputs, auto-save features, and any scenario where you want to avoid excessive API calls or re-renders while the user is actively typing.
- useFetch — Encapsulates data fetching with loading, error, and data states, plus support for cancellation via AbortController. More sophisticated versions include caching, retry logic, and stale-while-revalidate patterns.
- useMediaQuery — Listens to CSS media query changes and returns a boolean indicating whether the query matches. Useful for responsive logic that needs to live in JavaScript rather than CSS.
- useClickOutside — Detects clicks outside a specified element, commonly used for closing dropdowns, modals, and popovers. Attaches and cleans up document-level event listeners correctly.
Hooks vs External State Libraries
Understanding when to use built-in hooks versus external state management libraries is critical for choosing the right architecture. Here is a comparison of the most popular options.
Server State vs Client State
A critical distinction that simplifies state management is separating server state (data fetched from APIs) from client state (UI state like modals, form inputs, and user preferences). Libraries like TanStack Query (React Query) handle server state with built-in caching, background refetching, stale-while-revalidate, and optimistic updates. Once you extract server state into TanStack Query, the remaining client state is often simple enough to manage with useState or Zustand.
Common Mistakes to Avoid
- Storing everything in global state — Not every piece of state needs to be global. Form inputs, toggle states, and component-specific data should stay local. Only lift state to a global store when multiple unrelated components need access to the same data.
- Ignoring the dependency array — The ESLint plugin eslint-plugin-react-hooks exists for a reason. Its exhaustive-deps rule catches missing dependencies that lead to stale closures and bugs that are difficult to diagnose. Do not disable this rule.
- Overusing useEffect — Not every side effect belongs in useEffect. Event handlers, form submissions, and user interactions should be handled in event callbacks, not effects. The React team has documented this pattern as “You Might Not Need an Effect” in the official documentation.
- Mutating state directly — React state must be treated as immutable. Mutating arrays or objects in place (using push, splice, or direct property assignment) does not trigger re-renders and causes bugs. Always create new references when updating state.
- Premature abstraction — Do not create custom hooks for logic used in only one component. Extract into custom hooks when you have genuine reuse across two or more components, or when a component’s hook logic becomes too complex to understand at a glance.
Best practice: Start with the simplest state management approach (useState) and only add complexity when you have a clear need. Most applications need far less global state than developers initially assume. Keep state local, lift it only when necessary, and reach for external libraries only when built-in hooks are genuinely insufficient.
Frequently Asked Questions
Do I still need Redux in 2026?
It depends on your application’s complexity. For many applications, a combination of useState, useContext, and TanStack Query for server state is sufficient. Redux Toolkit remains valuable for large applications with complex client-side state, extensive middleware needs (like sagas or thunks for complex async flows), or teams that benefit from Redux DevTools’ time-travel debugging. If you are starting a new project, consider Zustand as a simpler alternative before reaching for Redux.
Can I use hooks in class components?
No, hooks can only be used in functional components and other custom hooks. If you have existing class components, you can incrementally migrate them to functional components with hooks, or create wrapper components that use hooks and pass data down as props. There is no need to rewrite all class components at once — functional and class components work together seamlessly in the same application.
How do I test custom hooks?
Use the renderHook utility from @testing-library/react. It renders a hook in a test component, allowing you to call the hook’s returned functions and assert on its state. For hooks that involve API calls, mock the fetch or axios calls. For hooks that use context, wrap the renderHook call in the appropriate provider. Testing hooks in isolation ensures they work correctly before integrating them into components.
What is the React Compiler and how will it affect hooks?
The React Compiler (previously called React Forget) is an experimental build-time tool that automatically memoizes components and hooks. When it ships, it will reduce or eliminate the need for manual useMemo, useCallback, and React.memo calls. However, it does not change how you write hooks fundamentally — useState, useEffect, useReducer, and useContext will continue to work the same way. The compiler optimizes the output, not the authoring experience.
About the author: Zach Campaner is an IT consultant and software engineer based in the Philippines with 15+ years of experience helping businesses build and scale their technology teams.
John from California
just requested a quote
2 minutes ago