React Hooks Made Simple: useState, useEffect, and More
React Hooks changed the way developers build components. Before Hooks, a lot of React code felt split into two worlds: class components for state and lifecycle methods, and functional components for everything else. That often made projects harder to read, harder to reuse, and harder to teach.
Hooks simplified that world.
Now, with a handful of functions like useState, useEffect, useRef, useMemo, and useCallback, you can build powerful React components without classes. More importantly, you can organize logic in a way that feels natural and reusable. Hooks are not just a feature. They are a style of thinking about React.
This article walks through Hooks in a practical, friendly way. We will start with the basics, then move into real examples, common mistakes, and patterns that make your code cleaner. By the end, Hooks should feel far less mysterious and a lot more useful.
Why Hooks Exist
Before Hooks, React components were often written in two forms:
Functional components for simple UI
Class components for state, lifecycle methods, and side effects
That split caused several problems. Logic that belonged together ended up separated across lifecycle methods. Reusing stateful logic was awkward. Many developers also found this confusing in class components.
Hooks solved those issues by letting functional components do almost everything class components can do, and often in a cleaner way.
The core idea is simple: Hooks let you “hook into” React features from function components.
That means you can:
store local state
run side effects
access DOM nodes
memoize expensive work
reuse logic across components
And you can do all of that without leaving the functional component world.
A Quick Look at a Hook
A Hook is just a JavaScript function that starts with the word use.
Example:
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increase</button>
</div>
);
}
At first glance, this might look tiny and almost too simple. But there is a lot happening here.
useState(0)creates a piece of state with an initial value of0countholds the current valuesetCountupdates that valuewhen state changes, React re-renders the component
That is the beauty of Hooks: they are compact, expressive, and very close to the problem they solve.
useState: The Hook You Will Use Constantly
useState is usually the first Hook people learn, and for good reason. It handles local component state.
Basic syntax
const [state, setState] = useState(initialValue);
You get two things back:
the current state value
a function to update it
Example: a simple counter
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
function handleIncrement() {
setCount(count + 1);
}
function handleDecrement() {
setCount(count - 1);
}
return (
<div>
<h2>Counter</h2>
<p>The current value is {count}</p>
<button onClick={handleDecrement}>-</button>
<button onClick={handleIncrement}>+</button>
</div>
);
}
This is the cleanest form of React state. No classes, no this.state, no binding headaches.
Updating state based on previous state
A very common mistake is writing:
setCount(count + 1);
This works in many cases, but when the new value depends on the old one, the safer approach is the updater function:
setCount(prevCount => prevCount + 1);
That is especially useful when React batches updates.
Example: using the previous value correctly
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
function handleTripleIncrement() {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleTripleIncrement}>Add 3</button>
</div>
);
}
If you used setCount(count + 1) three times, you might not get the result you expect. The updater form keeps things consistent.
Storing objects in state
State is not limited to numbers or strings. You can store objects too.
import { useState } from "react";
function ProfileForm() {
const [profile, setProfile] = useState({
firstName: "",
lastName: "",
email: ""
});
function handleChange(e) {
const { name, value } = e.target;
setProfile(prev => ({
...prev,
[name]: value
}));
}
return (
<form>
<input
name="firstName"
value={profile.firstName}
onChange={handleChange}
placeholder="First name"
/>
<input
name="lastName"
value={profile.lastName}
onChange={handleChange}
placeholder="Last name"
/>
<input
name="email"
value={profile.email}
onChange={handleChange}
placeholder="Email"
/>
</form>
);
}
Notice the spread operator:
{
...prev,
[name]: value
}
That keeps the other fields intact while updating just one.
Storing arrays in state
Arrays are common in React, especially for lists, carts, messages, tasks, and search results.
import { useState } from "react";
function TodoList() {
const [todos, setTodos] = useState(["Learn React", "Practice Hooks"]);
const [newTodo, setNewTodo] = useState("");
function addTodo() {
if (!newTodo.trim()) return;
setTodos(prev => [...prev, newTodo]);
setNewTodo("");
}
return (
<div>
<input
value={newTodo}
onChange={e => setNewTodo(e.target.value)}
placeholder="New todo"
/>
<button onClick={addTodo}>Add</button>
<ul>
{todos.map((todo, index) => (
<li key={index}>{todo}</li>
))}
</ul>
</div>
);
}
When working with arrays in state, do not mutate them directly with push, pop, or splice. Instead, create a new array. React relies on immutability to detect changes properly.
useEffect: Handling Side Effects in React
If useState is about data, useEffect is about what happens outside the normal rendering flow.
A side effect is anything that affects something outside the component or needs to happen after render, such as:
fetching data
changing the document title
setting up subscriptions
using timers
listening to events
syncing with local storage
Basic syntax
useEffect(() => {
// side effect code
}, [dependencies]);
The second argument is the dependency array. It tells React when to run the effect.
Example: updating the document title
import { useEffect, useState } from "react";
function PageTitleCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<button onClick={() => setCount(count + 1)}>
Count is {count}
</button>
);
}
Every time count changes, the effect runs and updates the browser tab title.
This is a simple example, but it shows the real purpose of useEffect: it keeps React components connected to the outside world.
How the Dependency Array Works
The dependency array is one of the most important concepts in React Hooks.
1. No dependency array
useEffect(() => {
console.log("Runs after every render");
});
This runs after every render.
2. Empty dependency array
useEffect(() => {
console.log("Runs only once on mount");
}, []);
This runs only once after the component mounts.
3. With dependencies
useEffect(() => {
console.log("Runs when count changes");
}, [count]);
This runs whenever count changes.
A real-world fetch example
import { useEffect, useState } from "react";
function UsersList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchUsers() {
try {
const response = await fetch("https://jsonplaceholder.typicode.com/users");
const data = await response.json();
setUsers(data);
} catch (error) {
console.error("Failed to load users:", error);
} finally {
setLoading(false);
}
}
fetchUsers();
}, []);
if (loading) {
return <p>Loading users...</p>;
}
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
The empty array means the fetch happens once when the component appears.
Cleanup in useEffect
Sometimes effects need cleanup. That is common with subscriptions, timers, or event listeners.
React lets you return a cleanup function from the effect.
Example: timer cleanup
import { useEffect, useState } from "react";
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
return () => clearInterval(intervalId);
}, []);
return <p>Seconds: {seconds}</p>;
}
The cleanup function runs when the component unmounts. That prevents memory leaks and unwanted background work.
Example: event listener cleanup
import { useEffect, useState } from "react";
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return <p>Window width: {width}px</p>;
}
Without cleanup, the event listener would continue running even after the component is removed.
useRef: Keeping a Mutable Value Without Re-rendering
useRef is useful when you need to hold onto a value without causing a re-render when it changes.
Common use cases
accessing DOM elements
storing timer IDs
keeping previous values
tracking mutable values across renders
Example: focusing an input
import { useRef } from "react";
function FocusInput() {
const inputRef = useRef(null);
function handleFocus() {
inputRef.current.focus();
}
return (
<div>
<input ref={inputRef} placeholder="Type something..." />
<button onClick={handleFocus}>Focus input</button>
</div>
);
}
Here, inputRef.current points to the actual DOM node.
Example: keeping previous value
import { useEffect, useRef, useState } from "react";
function PreviousCount() {
const [count, setCount] = useState(0);
const previousCount = useRef();
useEffect(() => {
previousCount.current = count;
}, [count]);
return (
<div>
<p>Current: {count}</p>
<p>Previous: {previousCount.current}</p>
<button onClick={() => setCount(count + 1)}>Increase</button>
</div>
);
}
Since refs do not trigger re-renders, they are perfect for values that should persist without affecting the UI directly.
useMemo: Optimizing Expensive Calculations
Sometimes your component needs to do a calculation that is expensive. useMemo helps by caching the result until dependencies change.
Example
import { useMemo, useState } from "react";
function ExpensiveCalculation() {
const [number, setNumber] = useState(1);
const [darkMode, setDarkMode] = useState(false);
const factorial = useMemo(() => {
function computeFactorial(n) {
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
}
return computeFactorial(number);
}, [number]);
return (
<div>
<button onClick={() => setDarkMode(!darkMode)}>
Toggle theme
</button>
<input
type="number"
value={number}
onChange={e => setNumber(Number(e.target.value))}
/>
<p>Factorial: {factorial}</p>
</div>
);
}
Without useMemo, the calculation could run on every render, even when unrelated state like darkMode changes.
A gentle warning
Do not use useMemo everywhere just because it exists. It is for real performance work, not as a default habit. In many apps, React is already fast enough. Use it when you have a measurable reason.
useCallback: Memoizing Functions
useCallback is similar to useMemo, but it is used for functions.
Basic syntax
const memoizedFunction = useCallback(() => {
// function body
}, [dependencies]);
Why it matters
In React, functions are recreated on every render. Sometimes that is fine. But if you pass a function as a prop to a child component that uses memoization, repeated recreation can trigger unnecessary renders.
Example
import { useCallback, useState } from "react";
const Button = React.memo(function Button({ onClick, label }) {
console.log(`Rendering ${label}`);
return <button onClick={onClick}>{label}</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Hassan");
const increment = useCallback(() => {
setCount(prev => prev + 1);
}, []);
return (
<div>
<p>{name}</p>
<p>{count}</p>
<Button onClick={increment} label="Increment" />
<input value={name} onChange={e => setName(e.target.value)} />
</div>
);
}
Here, increment stays stable between renders. That can help avoid unnecessary child renders.
Important note
Like useMemo, useCallback is not something to sprinkle everywhere. It is a tool for performance or identity stability, not decoration.
useContext: Sharing Data Without Prop Drilling
When many components need the same data, passing props through several layers can get messy. That is called prop drilling.
useContext helps by letting components read data from a shared context.
Example: theme context
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
function toggleTheme() {
setTheme(prev => (prev === "light" ? "dark" : "light"));
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
function ThemeButton() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
);
}
export default function App() {
return (
<ThemeProvider>
<ThemeButton />
</ThemeProvider>
);
}
This pattern is especially useful for app-wide settings such as:
theme
authentication
language
user preferences
cart data
useReducer: A Better Fit for Complex State
When your state logic becomes more complex, useReducer can be a cleaner choice than multiple useState calls.
It is especially useful when:
several actions can change the state
state transitions are predictable
the next state depends on the current one in structured ways
Example: a todo reducer
import { useReducer, useState } from "react";
const initialState = [];
function todoReducer(state, action) {
switch (action.type) {
case "add":
return [...state, { id: Date.now(), text: action.payload, done: false }];
case "toggle":
return state.map(todo =>
todo.id === action.payload ? { ...todo, done: !todo.done } : todo
);
case "delete":
return state.filter(todo => todo.id !== action.payload);
default:
return state;
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(todoReducer, initialState);
const [text, setText] = useState("");
function handleAdd() {
if (!text.trim()) return;
dispatch({ type: "add", payload: text });
setText("");
}
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={handleAdd}>Add Todo</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span
onClick={() => dispatch({ type: "toggle", payload: todo.id })}
style={{ textDecoration: todo.done ? "line-through" : "none", cursor: "pointer" }}
>
{todo.text}
</span>
<button onClick={() => dispatch({ type: "delete", payload: todo.id })}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
This structure makes state changes easier to follow, especially in larger apps.
useLayoutEffect: A More Immediate Effect
useLayoutEffect is similar to useEffect, but it runs synchronously after DOM updates and before the browser paints.
That makes it useful for reading layout or measuring elements before the user sees the screen update.
Example idea
import { useLayoutEffect, useRef, useState } from "react";
function BoxSize() {
const boxRef = useRef(null);
const [height, setHeight] = useState(0);
useLayoutEffect(() => {
if (boxRef.current) {
setHeight(boxRef.current.offsetHeight);
}
}, []);
return (
<div>
<div ref={boxRef} style={{ padding: "20px", border: "1px solid black" }}>
This box has content.
</div>
<p>Box height: {height}px</p>
</div>
);
}
In most cases, useEffect is enough. Reach for useLayoutEffect when visual measurement timing matters.
Building Your Own Custom Hooks
This is where Hooks become truly powerful.
A custom Hook is just a JavaScript function that starts with use and uses other Hooks inside. It lets you extract reusable logic from components.
Why custom Hooks matter
Without custom Hooks, you might repeat the same effect logic in multiple places:
fetching data
form handling
local storage sync
window size tracking
debounce logic
Custom Hooks let you move that logic into a clean reusable function.
Example: useLocalStorage
import { useEffect, useState } from "react";
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const storedValue = localStorage.getItem(key);
return storedValue ? JSON.parse(storedValue) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
function UsernameForm() {
const [name, setName] = useLocalStorage("username", "");
return (
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="Enter your name"
/>
);
}
Now the local storage logic is reusable and neat.
Example: useFetch
import { useEffect, useState } from "react";
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
async function fetchData() {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
const result = await response.json();
if (!ignore) {
setData(result);
}
} catch (err) {
if (!ignore) {
setError(err.message);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
fetchData();
return () => {
ignore = true;
};
}, [url]);
return { data, loading, error };
}
Using it:
function Posts() {
const { data, loading, error } = useFetch("https://jsonplaceholder.typicode.com/posts");
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{data.slice(0, 5).map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
That is the real magic of custom Hooks: they let you build your own small React toolbox.
Rules of Hooks You Should Never Break
Hooks are powerful, but they come with rules. React depends on these rules to keep Hook behavior predictable.
Rule 1: Only call Hooks at the top level
Do not call Hooks inside loops, conditions, nested functions, or callbacks.
Wrong:
if (loggedIn) {
useEffect(() => {
console.log("This is wrong");
}, []);
}
Right:
useEffect(() => {
if (loggedIn) {
console.log("This is fine");
}
}, [loggedIn]);
Rule 2: Only call Hooks from React functions
That means:
React function components
custom Hooks
Not regular JavaScript functions.
Why these rules exist
React relies on the order of Hook calls. If the order changes between renders, React can no longer match state and effects correctly.
That is why the rules are strict. They are not arbitrary. They protect your app from subtle bugs.
Common Mistakes with Hooks
Even experienced developers make Hook mistakes. These are some of the most common ones.
1. Forgetting dependencies in useEffect
useEffect(() => {
console.log(name);
}, []);
If name is used inside the effect, it should usually appear in the dependency array.
Correct:
useEffect(() => {
console.log(name);
}, [name]);
2. Mutating state directly
Wrong:
todos.push("New item");
setTodos(todos);
Right:
setTodos(prev => [...prev, "New item"]);
3. Using useEffect for logic that belongs in event handlers
Not everything belongs in an effect. For example, showing a message when a button is clicked should usually happen in the click handler, not in useEffect.
4. Overusing useMemo and useCallback
These Hooks are useful, but they also make code more complex. Use them when they help, not just because you can.
5. Creating infinite loops in effects
Example:
useEffect(() => {
setCount(count + 1);
}, [count]);
This updates count, which causes the effect to run again, which updates count again. That creates an infinite loop.
When to Use Which Hook
A simple way to think about it:
useState: store local data
useEffect: run side effects after render
useRef: hold values without re-rendering, or access DOM
useMemo: cache expensive calculations
useCallback: cache function references
useContext: share data across components
useReducer: manage complex state transitions
useLayoutEffect: measure layout before paint
custom Hooks: reuse logic
This mental map makes Hooks easier to choose.
A Small Real Example: Search and Filter
Let’s build something practical: a small searchable list.
import { useMemo, useState } from "react";
const products = [
"Laptop",
"Phone",
"Headphones",
"Keyboard",
"Mouse",
"Monitor",
"Tablet"
];
function ProductSearch() {
const [query, setQuery] = useState("");
const filteredProducts = useMemo(() => {
return products.filter(product =>
product.toLowerCase().includes(query.toLowerCase())
);
}, [query]);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search products"
/>
<ul>
{filteredProducts.map(product => (
<li key={product}>{product}</li>
))}
</ul>
</div>
);
}
This example shows a nice combination of Hooks:
useStatefor the search termuseMemofor the filtered list
It is simple, but it feels like real app code.
Another Real Example: Toggle Theme with Context and State
Here is a classic pattern for small app-wide settings.
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext(null);
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggleTheme = () => {
setTheme(prev => (prev === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<div className={theme}>
{children}
</div>
</ThemeContext.Provider>
);
}
function ThemeSwitcher() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<button onClick={toggleTheme}>
Switch to {theme === "light" ? "dark" : "light"} mode
</button>
);
}
function App() {
return (
<ThemeProvider>
<ThemeSwitcher />
</ThemeProvider>
);
}
This is the kind of structure that makes a real application feel organized without overengineering it.
Thinking Like a React Developer
One of the biggest mindset shifts with Hooks is learning to think in terms of data flow and effects rather than lifecycles and classes.
That means asking:
What data does this component need?
What should happen when that data changes?
What side effect should run after render?
What state belongs here versus in a parent component?
Is this logic reusable enough to become a custom Hook?
These questions help you design better components.
A good React component is usually small, focused, and easy to reason about. Hooks help you keep it that way.
A Mini Project: Notes App with Hooks
Let’s bring several Hooks together in one simple notes app.
import { useEffect, useState } from "react";
function NotesApp() {
const [notes, setNotes] = useState(() => {
const saved = localStorage.getItem("notes");
return saved ? JSON.parse(saved) : [];
});
const [text, setText] = useState("");
useEffect(() => {
localStorage.setItem("notes", JSON.stringify(notes));
}, [notes]);
function addNote() {
if (!text.trim()) return;
setNotes(prev => [...prev, { id: Date.now(), text }]);
setText("");
}
function deleteNote(id) {
setNotes(prev => prev.filter(note => note.id !== id));
}
return (
<div>
<h2>My Notes</h2>
<textarea
value={text}
onChange={e => setText(e.target.value)}
placeholder="Write a note..."
/>
<button onClick={addNote}>Save Note</button>
<ul>
{notes.map(note => (
<li key={note.id}>
{note.text}
<button onClick={() => deleteNote(note.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
This little app uses:
useStatefor notes and text inputuseEffectto save notes to local storagefunctional updates to avoid stale state issues
This is a great example of how Hooks help you build something useful without making the code hard to follow.
Best Practices for Clean Hook Code
A few habits make Hook-based code much easier to maintain.
Keep state close to where it is used
Do not lift state higher than necessary. If a value is only used in one component, keep it there.
Split logic into custom Hooks
If you see the same effect logic more than once, extract it.
Avoid giant components
Hooks work best when components stay focused. A component that tries to do everything becomes hard to maintain.
Use the dependency array honestly
Do not “cheat” by removing dependencies just to silence warnings unless you fully understand the implications.
Prefer readable code over clever code
Hooks are meant to make React easier, not more mysterious.
Hooks and Human-Friendly Code
One reason many developers enjoy Hooks is that they make code feel more like plain JavaScript again.
You can read a component top to bottom and understand:
what state it keeps
what happens when that state changes
what side effects run
what gets rendered
That readability matters. Code is written once, but read many times. Hooks help you write code that your future self will thank you for.
Final Thoughts
React Hooks are not just a replacement for class component features. They are a cleaner way to structure React logic.
useState gives you local state.useEffect lets you synchronize with the outside world.useRef keeps mutable values and DOM references.useMemo and useCallback help with performance and identity.useContext reduces prop drilling.useReducer helps with more complex state.
Custom Hooks let you package reusable behavior into something elegant.
The more you use Hooks, the more natural they become. At first, they may look like a set of separate tools. Over time, they start to feel like one flexible system for building modern React apps.
That is the real value of Hooks: they help you write components that are easier to understand, easier to reuse, and easier to grow.
React became more powerful when Hooks arrived, but it also became more human. And that is a very good thing.
Quick Practice Ideas
Try building a few small components using Hooks:
a counter with increment, decrement, and reset
a form with live input tracking
a theme toggle with context
a timer with cleanup
a todo list with
useReducera search box with filtered results
Each one will help the ideas settle in faster than reading alone.
One Last Example: Counter with Reset
Here is a clean final example that ties together the basics:
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<h2>Counter</h2>
<p>{count}</p>
<button onClick={() => setCount(prev => prev - 1)}>-</button>
<button onClick={() => setCount(0)}>Reset</button>
<button onClick={() => setCount(prev => prev + 1)}>+</button>
</div>
);
}
Simple. Clear. Useful.
That is often the best style of React code.