React Hooks Explained: Guide to the Six Hooks You'll Use Most

Every React developer remembers the first time they saw a component with lifecycle methods, constructor functions, and this.setState(...) calls and thought: "There has to be a better way."
React Hooks are that better way.
Introduced in React 16.8, Hooks changed how developers write React components. Before Hooks, managing state and side effects required class-based components, which were verbose, harder to reuse, and easy to get wrong. Hooks let you do all of that inside simple function components, using clean and readable JavaScript.
Today, Hooks are the standard in modern React development. Whether you're building a personal project or working on a large-scale application, understanding Hooks is non-negotiable.
In this article, we'll walk through six of the most commonly used React Hooks:
useState— managing component stateuseEffect— handling side effectsuseRef— referencing DOM elements and persisting valuesuseMemo— optimising expensive calculationsuseContext— sharing data across componentsuseCallback— memoising functions to prevent unnecessary re-renders
1. useState — Giving Your Component a Memory
What is it?
useState is the most fundamental hook in React. It enables a component to remember information, such as whether a button was clicked, what a user inserted into a form, or how many product items are in a cart.
Without state, your component would render the same output every single time, ignoring any user interaction. State is what makes your UI dynamic and responsive.
How it works
import { useState } from 'react';
function Counter() {
// Declare a state variable called "count", starting at 0
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
{/* Call setCount to update the state */}
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
useState(0) sets the initial value of count to 0. It returns two things: the current value (count) and a function to update it (setCount). Every time setCount is called, React re-renders the component with the new value.
When to use it
Use useState whenever a component needs to track something that can change such as a toggle, a form field value, a counter, a list of items, or a loading status.
Real-world use case
Think of a dark mode toggle. When the user clicks the button, you update a isDarkMode state variable from false to true, and your component re-renders with the appropriate styles applied. Simple, clean, effective.
2. useEffect — Running Code at the Right Time
What is it?
useEffect allows you to create side effects in your components. A "side effect" is any action that extends beyond the component, such as fetching data from an API, updating the page title, setting up a timer, or subscribing to an event.
Without useEffect, you'd have no clean way to say "run this code after the component renders." That's exactly the problem it solves.
How it works
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// This runs after the component renders
fetch(`https://api.example.com/users/${userId}`)
.then(response => response.json())
.then(data => setUser(data));
}, [userId]); // Re-run this effect whenever userId changes
if (!user) return <p>Loading...</p>;
return <h1>Hello, {user.name}!</h1>;
}
The second argument — [userId] — is the dependency array. It tells React when to re-run the effect. If you pass an empty array [], the effect runs only once, after the first render. If you pass a variable like userId, the effect re-runs every time that variable changes.
When to use it
Use useEffect when you need to:
- Fetch data from an API
- Set or clear a timer
- Subscribe or unsubscribe from events
- Update the browser tab title
Real-world use case
An e-commerce product page fetches product details when the page loads. When the user navigates to a different product (changing the product ID in the URL), useEffect detects the change and fetches the new product's data automatically.
3. useRef — A Sticky Note That Doesn't Cause Re-renders
What is it?
useRef gives you a way to hold a reference to something, usually a DOM element or a value you want to persist between renders and without causing the component to re-render when that value changes.
Consider it as a sticky note attached to your component. You can write on it, read it back, and update it without affecting the component's display.
How it works
Example 1: Accessing a DOM element
import { useRef } from 'react';
function TextInput() {
// Create a ref to attach to an input element
const inputRef = useRef(null);
const focusInput = () => {
// Directly access and focus the input element
inputRef.current.focus();
};
return (
<div>
<input ref={inputRef} type="text" placeholder="Type here..." />
<button onClick={focusInput}>Focus the input</button>
</div>
);
}
Example 2: Storing a value without triggering re-renders
import { useRef, useEffect } from 'react';
function Timer() {
// Store the interval ID without causing re-renders
const intervalRef = useRef(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
console.log('Tick');
}, 1000);
// Clear the interval when the component unmounts
return () => clearInterval(intervalRef.current);
}, []);
return <p>Timer is running. Check the console.</p>;
}
When to use it
Use useRef when you need to:
- Directly interact with a DOM element (focus, scroll, measure)
- Store a value that should persist between renders but shouldn't trigger a re-render when it changes
- Keep track of a previous state value
Real-world use case
A video player component uses useRef to access the <video> element directly, allowing the play and pause buttons to control playback by calling videoRef.current.play() and videoRef.current.pause().
4. useMemo — Don't Recalculate What You Don't Have To
What is it?
useMemo is a performance optimisation tool. It memoises (caches) the result of an expensive calculation and only recalculates it when the inputs change.
When React re-renders a component, it re-runs all the code inside it. If your component performs a significant computation, such as filtering a large list, sorting thousands of items, or performing sophisticated math, the calculation is executed on every render, even if the result remains unchanged. useMemo prevents wasted work.
How it works
import { useState, useMemo } from 'react';
function ProductList({ products, searchTerm }) {
// Only re-filter the products when products or searchTerm changes
const filteredProducts = useMemo(() => {
console.log('Filtering products...'); // Won't log unless inputs change
return products.filter(product =>
product.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [products, searchTerm]);
return (
<ul>
{filteredProducts.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
Without useMemo, the filter would run on every re-render, even if the user had simply clicked a button elsewhere on the page unrelated to the product list. Using useMemo, the result is cached and utilized until products or searchTerm changes.
When to use it
Use useMemo when:
- You have a calculation that is noticeably slow or processes large datasets
- The calculation's inputs change infrequently
A word of caution: Don't use
useMemoeverywhere. It adds complexity and has its own overhead. Use it only when you've actually noticed a performance problem and not as a default habit.
Real-world use case
A data analytics dashboard filters and sorts thousands of rows of data. Without useMemo, scrolling through the page or toggling an unrelated UI element would trigger the entire filter operation again. With useMemo, that work is skipped unless the underlying data or filter criteria change.
5. useContext — Sharing Data Without Passing It Everywhere
What is it?
useContext solves a common problem in React called prop drilling by eliminating the frustrating pattern where you pass data down through multiple layers of components just so a deeply nested child can access it.
With useContext, you can make data available to any component in your tree, at any depth, without threading it through every layer in between.
How it works
It takes two steps: first, you create a context and provide it at a high level; then, any child component can consume it with useContext.
Step 1: Create and provide the context
import { createContext, useState } from 'react';
// Create a context with a default value
export const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('light');
return (
// Wrap your component tree with the Provider
// Every child can now access "theme"
<ThemeContext.Provider value={theme}>
<Layout />
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</ThemeContext.Provider>
);
}
Step 2: Consume the context in any child component
import { useContext } from 'react';
import { ThemeContext } from './App';
function Button() {
// Access the theme directly — no props needed
const theme = useContext(ThemeContext);
return (
<button className={`btn btn-${theme}`}>
I know the current theme!
</button>
);
}
Button can be nested five levels deep inside Layout, and it will still have access to theme without theme ever being passed as a prop to any of those intermediate components.
When to use it
Use useContext for data that is truly global to your application — such as:
- The currently logged-in user
- The active theme (light/dark)
- The preferred language or locale
- A shopping cart shared across multiple pages
Real-world use case
A UserContext is used in multi-page applications to store information about the logged-in user. Every component, from the top navigation bar with the user's name to a settings page with their preferences, gets this information directly from useContext, without any prop drilling required.
6. useCallback — Keeping Functions Stable Across Renders
What is it?
Every time a React component re-renders, every function defined inside it is recreated from scratch. Most of the time, this is harmless. But when you pass a function down to a child component, that child sees a brand-new function on every render, even if the function does exactly the same thing it did before. This can cause unnecessary re-renders in child components, quietly hurting the performance of your application.
useCallback addresses this issue by memoising a function, which returns the same function instance across renderings unless its dependencies change. Think of it as useMemo, but specifically for functions rather than values.
How it works
Let's first look at the problem without useCallback:
import { useState } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [darkMode, setDarkMode] = useState(false);
// ⚠️ This function is recreated on EVERY render
// — even when only darkMode changes
const handleSearch = (searchTerm) => {
console.log('Searching for:', searchTerm);
// ...fetch results
};
return (
<div>
<button onClick={() => setDarkMode(!darkMode)}>Toggle Theme</button>
{/* SearchBox receives a new handleSearch every render */}
<SearchBox onSearch={handleSearch} />
</div>
);
}
Every time the user toggles the theme, SearchPage re-renders and creates a new handleSearch function. SearchBox receives what looks like a changed prop and re-renders too — even though the search logic didn't change at all.
Now, with useCallback:
import { useState, useCallback } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [darkMode, setDarkMode] = useState(false);
// ✅ This function is only recreated when "query" changes
const handleSearch = useCallback((searchTerm) => {
console.log('Searching for:', searchTerm);
// ...fetch results
}, [query]); // dependency array — same as useEffect and useMemo
return (
<div>
<button onClick={() => setDarkMode(!darkMode)}>Toggle Theme</button>
{/* SearchBox now receives the same function reference — no unnecessary re-render */}
<SearchBox onSearch={handleSearch} />
</div>
);
}
By wrapping handleSearch in useCallback, React reuses the same function reference as long as query hasn't changed. Toggling the theme no longer causes SearchBox to re-render unnecessarily.
useCallback vs useMemo — what's the difference?
They're closely related, and it's easy to mix them up:
useMemocaches the result of a function — the value it returns.useCallbackcaches the function itself — the callable reference.
In fact, useCallback(fn, deps) is essentially shorthand for useMemo(() => fn, deps). If you need to memoise a value, use useMemo. If you need to memoise a function, use useCallback.
When to use it
Use useCallback when:
- You're passing a function as a prop to a child component that is wrapped in
React.memo(a tool that tells React to skip re-rendering a child if its props haven't changed) - A function is listed as a dependency in a
useEffectand you don't want the effect to re-run on every render - You're passing callbacks into performance-sensitive components like large lists or data tables
Same caution as
useMemo: Don't reach foruseCallbackby default. It adds its own overhead. Use it when you have a real, observed performance issue and not as a precaution.
Real-world use case
Imagine a large comment feed where each comment has a "Like" button. The parent component manages the list and passes a handleLike function to every Comment component. Without useCallback, every state update in the parent (say, a notification counter ticking up) would recreate handleLike and re-render every single comment card. Wrapping handleLike in useCallback ensures those comment cards stay still unless the function's logic actually needs to change.
Putting It All Together
These six Hooks cover the vast majority of what you'll need in real React development:
| Hook | Purpose | Use When... |
|---|---|---|
useState |
Manage component state | Your component needs to track changing data |
useEffect |
Run side effects | You need to fetch data, set timers, or subscribe to events |
useRef |
Reference DOM elements or persist values | You need direct DOM access or a value that doesn't trigger re-renders |
useMemo |
Optimise expensive calculations | A slow calculation is hurting performance |
useContext |
Share data globally | Multiple components need the same data without prop drilling |
useCallback |
Memoise functions | You're passing functions to child components and want to prevent unnecessary re-renders |
Conclusion
React Hooks transformed how developers write React applications. They made code shorter, easier to read, and far simpler to reuse. Once you get comfortable with useState and useEffect, the others will begin to feel natural very quickly.
The most effective way to properly learn Hooks is to apply them. Begin small: create a counter using useState, retrieve some data with useEffect, and toggle a theme with useContext. When your program expands and speed becomes important, use useMemo and useCallback to keep things fast and efficiently.
These six Hooks are the backbone of modern React development. Master them, and you'll have everything you need to write clean, professional, and performant React code.
Clean, hook-powered React code is within your reach. The only step left is to open your editor and start building.
Happy coding — and remember, every expert was once a beginner who just kept going. 😎
