React Hooks are one of the most important concepts in modern React development.
They allow functional components to use state, manage side effects, access context, reference DOM elements, optimize performance, and reuse stateful logic.
Before Hooks were introduced, many React features required class components. Hooks changed that by making powerful React capabilities available directly inside function components.
In this guide, we'll explore the most important React Hooks, understand when to use them, avoid common mistakes, and build practical examples.
By the end, you'll understand not only how Hooks work, but also how to choose the right Hook for a particular problem.
1. What Are React Hooks?
Hooks are special functions provided by React that let function components use React features.
For example:
import { useState } from "react"; function Counter() { const [count, setCount] = useState(0); return ( <button onClick={() => setCount(count + 1)}> Count: {count} </button> ); } Here, useState allows a function component to remember a value between renders.
Without Hooks, function components were primarily used for presenting UI based on props.
With Hooks, function components can manage much more complex behavior.
Why Were Hooks Introduced?
Hooks help solve several problems:
- Reusing stateful logic between components.
- Avoiding large and complicated class components.
- Organizing related logic together.
- Managing component state and lifecycle behavior.
- Making React code easier to understand and maintain.
The most important idea is that Hooks let you organize component logic around features rather than lifecycle methods.
2. Rules of Hooks
Before learning individual Hooks, you must understand two important rules.
Rule 1: Only Call Hooks at the Top Level
Never call Hooks inside:
- Conditions.
- Loops.
- Nested functions.
- Event handlers.
-
try/catchblocks.
Incorrect:
function Profile({ isLoggedIn }) { if (isLoggedIn) { const [name, setName] = useState("Alex"); } return <h1>Profile</h1>; } Correct:
function Profile({ isLoggedIn }) { const [name, setName] = useState("Alex"); if (!isLoggedIn) { return <p>Please log in.</p>; } return <h1>Hello, {name}</h1>; } Why?
React relies on the order of Hook calls to associate Hook state with the correct component.
If Hooks are called conditionally, their order can change between renders, causing bugs.
Rule 2: Only Call Hooks from React Functions
Hooks should be called from:
- React function components.
- Custom Hooks.
Incorrect:
function calculateSomething() { const [value, setValue] = useState(0); } Correct:
function useCounter() { const [value, setValue] = useState(0); return { value, setValue }; } Custom Hooks are discussed later in this article.
3. useState — Managing Component State
useState is probably the Hook you'll use most frequently.
It allows a component to store information that can change over time.
Basic Syntax
const [state, setState] = useState(initialValue); It returns an array containing:
- The current state value.
- A function for updating that value.
Example:
import { useState } from "react"; function Counter() { const [count, setCount] = useState(0); return ( <div> <h2>Count: {count}</h2> <button onClick={() => setCount(count + 1)}> Increase </button> <button onClick={() => setCount(count - 1)}> Decrease </button> </div> ); } When setCount is called, React schedules an update and renders the component with the new state.
Updating State Based on Previous State
Sometimes you need to calculate the next state using the previous state.
In this situation, use the functional updater form.
setCount(previousCount => previousCount + 1); For example:
function Counter() { const [count, setCount] = useState(0); function increaseThreeTimes() { setCount(value => value + 1); setCount(value => value + 1); setCount(value => value + 1); } return ( <div> <h2>{count}</h2> <button onClick={increaseThreeTimes}> Increase by 3 </button> </div> ); } Why use this approach?
State values are snapshots associated with a particular render. Multiple updates that depend on the previous value should use updater functions to avoid calculating from an outdated snapshot.
Managing Objects with useState
You can also store objects in state.
function UserProfile() { const [user, setUser] = useState({ name: "Alex", age: 25, city: "Berlin" }); function updateAge() { setUser(previousUser => ({ ...previousUser, age: previousUser.age + 1 })); } return ( <div> <h2>{user.name}</h2> <p>Age: {user.age}</p> <p>City: {user.city}</p> <button onClick={updateAge}> Increase Age </button> </div> ); } Notice the use of the spread operator:
{ ...previousUser, age: previousUser.age + 1 } React state should be treated as immutable.
Instead of modifying the existing object, create a new object with the updated properties.
Managing Arrays with useState
function TodoList() { const [todos, setTodos] = useState([ "Learn React", "Practice JavaScript" ]); function addTodo() { setTodos(previousTodos => [ ...previousTodos, "Build a project" ]); } return ( <div> <ul> {todos.map((todo, index) => ( <li key={index}>{todo}</li> ))} </ul> <button onClick={addTodo}> Add Todo </button> </div> ); } For lists that can be reordered, inserted into, or deleted from, use stable unique IDs instead of array indexes as keys.
4. useEffect — Synchronizing with External Systems
useEffect is one of the most misunderstood React Hooks.
It allows a component to synchronize with systems outside React.
Examples include:
- Fetching data from an API.
- Subscribing to browser events.
- Connecting to a WebSocket.
- Starting and cleaning up timers.
- Synchronizing with third-party libraries.
- Updating certain external browser APIs.
Basic Syntax
useEffect(() => { // Synchronization logic return () => { // Cleanup logic }; }, [dependencies]); The dependency array determines when React needs to re-synchronize the Effect.
Example 1: An Effect After Every Commit
import { useEffect, useState } from "react"; function Counter() { const [count, setCount] = useState(0); useEffect(() => { console.log("Component committed."); }); return ( <button onClick={() => setCount(count + 1)}> Count: {count} </button> ); } There is no dependency array.
The Effect runs after every commit of this component.
Example 2: An Effect with an Empty Dependency Array
useEffect(() => { console.log("Effect setup"); }, []); This Effect has no reactive dependencies.
It runs when the component mounts, and its cleanup runs when the component unmounts.
In development, React Strict Mode may perform an additional setup-and-cleanup cycle to help reveal bugs. Therefore, don't assume an Effect's setup always runs exactly once in every environment.
Example 3: An Effect with Dependencies
function UserProfile({ userId }) { useEffect(() => { console.log("User ID changed:", userId); }, [userId]); return <h2>User: {userId}</h2>; } The Effect runs after the initial commit and again when userId changes.
Understanding Dependency Arrays
| Dependency array | When the Effect runs |
|---|---|
| No array | After every commit |
[] | After initial mount |
[value] | After mount and when value changes |
React compares dependency values using Object.is.
Every reactive value used by an Effect should be included in its dependency array.
Don't remove dependencies just to force an Effect to run less frequently. Instead, restructure the code so that the Effect genuinely depends only on the values it needs.
5. useEffect Cleanup
Some Effects create resources that must be released.
For example:
- Event listeners.
- Timers.
- WebSocket connections.
- Subscriptions.
React lets you return a cleanup function from an Effect.
Example:
import { useEffect } from "react"; function WindowWidth() { useEffect(() => { function handleResize() { console.log(window.innerWidth); } window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); }; }, []); return <p>Resize the browser window.</p>; } The cleanup function runs:
- Before the Effect runs again with changed dependencies.
- When the component unmounts.
This prevents old subscriptions and event listeners from remaining active.
Practical Example: A Timer
import { useEffect, useState } from "react"; function Timer() { const [seconds, setSeconds] = useState(0); useEffect(() => { const intervalId = setInterval(() => { setSeconds(previous => previous + 1); }, 1000); return () => { clearInterval(intervalId); }; }, []); return <h2>Seconds: {seconds}</h2>; } The cleanup function stops the interval when the component is removed.
6. Fetching API Data with useEffect
A common use case for Effects is synchronizing a component with data from an API.
Let's build a simple example.
import { useEffect, useState } from "react"; function Users() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); async function fetchUsers() { try { setLoading(true); setError(null); const response = await fetch( "https://jsonplaceholder.typicode.com/users", { signal: controller.signal } ); if (!response.ok) { throw new Error("Failed to fetch users"); } const data = await response.json(); setUsers(data); } catch (error) { if (error.name !== "AbortError") { setError(error.message); } } finally { if (!controller.signal.aborted) { setLoading(false); } } } fetchUsers(); return () => { controller.abort(); }; }, []); if (loading) { return <p>Loading users...</p>; } if (error) { return <p>Error: {error}</p>; } return ( <ul> {users.map(user => ( <li key={user.id}> {user.name} — {user.email} </li> ))} </ul> ); } This example demonstrates several important concepts:
- Loading state.
- Error handling.
- API requests.
- Effect cleanup.
- Request cancellation.
- Rendering dynamic data.
Important: useEffect is not automatically the best solution for every data-fetching requirement. Larger applications often benefit from framework data-loading features or dedicated libraries that handle caching, request deduplication, retries, and server rendering.
A Common useEffect Mistake
Avoid using Effects to calculate values that can be calculated directly during rendering.
Unnecessary:
function Product({ price, quantity }) { const [total, setTotal] = useState(0); useEffect(() => { setTotal(price * quantity); }, [price, quantity]); return <p>Total: {total}</p>; } Better:
function Product({ price, quantity }) { const total = price * quantity; return <p>Total: {total}</p>; } Why?
The total is derived from existing props. It doesn't need separate state or an Effect.
Use Effects for synchronization with external systems, not as a general mechanism for responding to every change in React data.
7. useRef — Referencing DOM Elements and Storing Mutable Values
useRef creates an object that persists between renders.
Its .current property can be changed without triggering a re-render.
Basic Syntax
const myRef = useRef(initialValue); Example 1: Accessing a DOM Element
import { useRef } from "react"; function SearchBox() { const inputRef = useRef(null); function focusInput() { inputRef.current.focus(); } return ( <div> <input ref={inputRef} type="text" /> <button onClick={focusInput}> Focus Input </button> </div> ); } Let's understand what happens:
-
useRef(null)creates a reference object. - The object is assigned to the input using the
refattribute. - After React attaches the DOM element,
inputRef.currentpoints to it. - Calling
.focus()focuses the input.
Example 2: Storing a Previous Value
import { useEffect, useRef } from "react"; function Counter({ count }) { const previousCount = useRef(count); useEffect(() => { previousCount.current = count; }, [count]); return ( <div> <p>Current: {count}</p> <p>Previous: {previousCount.current}</p> </div> ); } The ref stores a value between renders without causing another render when it changes.
useState vs useRef
| Feature | useState | useRef |
|---|---|---|
| Stores values | Yes | Yes |
| Persists between renders | Yes | Yes |
| Updating triggers render | Yes | No |
| Common use | UI state | DOM references and mutable values |
Remember: If changing a value should update the UI, state is usually the right choice. If the value must persist but doesn't need to trigger rendering, a ref may be appropriate.
8. useContext — Sharing Data Across Components
Sometimes multiple components need access to the same information.
For example:
- Current user.
- Theme settings.
- Language preferences.
- Application configuration.
Passing props through many intermediate components can become inconvenient. This is often called prop drilling.
React Context provides a way to share values across a component tree.
Step 1: Create a Context
import { createContext } from "react"; export const ThemeContext = createContext("light"); Step 2: Provide the Context Value
import { ThemeContext } from "./ThemeContext"; function App() { return ( <ThemeContext.Provider value="dark"> <Dashboard /> </ThemeC...