React Hooks Explained: A Comprehensive Guide with Examples

React Hooks revolutionized the way we design components, but what distinguishes clean code from clumsy workarounds is knowing when and why to apply each one. The six most crucial hooks are broken down in this article, along with useful examples that you may utilize immediately.
Prerequisite
A basic understanding of React components and JavaScript is needed to follow along with this article.
The Problem Before Hooks
It was difficult to manage state and side effects in functional components prior to the introduction of React Hooks in React 16.8. Stateful logic and lifecycle methods were primarily handled by class components. As a result, the code was difficult to comprehend and reuse:
Wrapper Hell: Complex components with numerous layers of render props or higher-order components (HOCs) are referred to as "Wrapper Hell."
Confusing Classes: binding problems and the potentially difficult requirement to comprehend class component lifecycle procedures.
Logic Reuse: Reusing stateful logic across several components without restructuring is challenging.
These issues were resolved via hooks, which made our code clearer, easier to read, and more reusable by enabling us to leverage state and other React capabilities directly in functional components.
#1. useState: Component State Management
To add state to functional components, the useState hook is essential. It enables you to declare state variables so that their values are maintained during re-renders.
How it Works
useState returns an array with two elements:
The current state value.
A function that lets you update the state.
When you call the update function, React re-renders the component.
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
export default Counter;
useState(0) initializes count to 0. Calling setCount triggers a re-render with the updated value. You can also have multiple independent state variables in a single component:
import React, { useState } from 'react';
function UserProfile() {
const [name, setName] = useState('John Doe');
const [age, setAge] = useState(30);
return (
<div>
<p>Name: {name}</p>
<p>Age: {age}</p>
<button onClick={() => setName('Jane Smith')}>Change Name</button>
<button onClick={() => setAge(age + 1)}>Increment Age</button>
</div>
);
}
export default UserProfile;
2. useEffect: Handling Side Effects
You can execute side effects in functional components, such as data fetching, subscriptions, manually altering the DOM, and timers, using the useEffect hook. It takes the place of class components' componentDidMount, componentDidUpdate, and componentWillUnmount.
How it Works
useEffect takes two arguments:
A function containing the side effect logic.
An optional dependency array that controls when the effect re-runs.
Basic Usage (Runs after every render)
import React, { useState, useEffect } from 'react';
function TitleUpdater() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
export default TitleUpdater;
Running Only Once (Empty Dependency Array)
To run an effect only after the first render, pass an empty array []; this is comparable to componentDidMount.
import React, { useState, useEffect } from 'react';
function DataFetcher() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(response => response.json())
.then(json => {
setData(json);
setLoading(false);
});
}, []);
if (loading) return <p>Loading data...</p>;
return (
<div>
<p>Title: {data.title}</p>
<p>Completed: {data.completed ? 'Yes' : 'No'}</p>
</div>
);
}
export default DataFetcher;
Running on Specific Dependency Changes
Include values in the dependency array to re-run the effect only when those values change.
import React, { useState, useEffect } from 'react';
function UserGreeting({ userId }) {
const [userName, setUserName] = useState('');
useEffect(() => {
fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
.then(response => response.json())
.then(user => setUserName(user.name));
}, [userId]); // Re-runs whenever userId changes
return (
<p>Hello, {userName}!</p>
);
}
export default UserGreeting;
Cleanup Function
When the component unmounts or before the effect reruns because of a dependency change, useEffect can return a cleanup function. This is the equivalent of componentWillUnmount; you can unsubscribe, remove timers, or release resources here.
import React, { useState, useEffect } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setSeconds(prevSeconds => prevSeconds + 1);
}, 1000);
return () => {
clearInterval(intervalId);
};
}, []);
return (
<p>Timer: {seconds} seconds</p>
);
}
export default Timer;
3. useRef: Accessing DOM Elements and Mutable Values
A changeable ref object that endures between renderings is created by the useRef hook. There are two primary uses for it:
Directly accessing the DOM to measure element dimensions, play media, and focus inputs.
Storing mutable values: storing any value that must remain constant between renders without causing a re-render when it changes.
How It Works
You can read or write straight to the plain JavaScript object that useRef provides, which has a single attribute called "current."
Accessing a DOM Element
import React, { useRef } from 'react';
function FocusInput() {
const inputRef = useRef(null);
const handleFocus = () => {
if (inputRef.current) {
inputRef.current.focus();
}
};
return (
<div>
<input type="text" ref={inputRef} />
<button onClick={handleFocus}>Focus Input</button>
</div>
);
}
export default FocusInput;
The DOM node is attached to inputRef via the ref prop. You may access that element directly once it's attached using inputRef.current; you don't need to query the DOM yourself.
Storing Modifiable Values (Avoiding Re-renders)
Updating a ref does not result in a re-render, in contrast to useState. It is therefore perfect for storing timer IDs or recording past values.
import React, { useState, useEffect, useRef } from 'react';
function PreviousValueTracker() {
const [count, setCount] = useState(0);
const prevCountRef = useRef();
useEffect(() => {
prevCountRef.current = count;
}, [count]);
const prevCount = prevCountRef.current;
return (
<div>
<p>Current Count: {count}</p>
<p>Previous Count: {prevCount}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
export default PreviousValueTracker;
4. useMemo: Memoizing Expensive Calculations
A performance optimization hook called useMemo stores a calculation's outcome and only reruns it when its dependents change. Without it, costly calculations are repeated for each render, even if the inputs are the same.
How it Works
useMemo takes two arguments:
A function that performs the calculation.
A dependency array.
It returns the cached value, recomputing only when a dependency changes.
import React, { useState, useMemo } from 'react';
function FactorialCalculator() {
const [number, setNumber] = useState(1);
const [incrementor, setIncrementor] = useState(0);
const calculateFactorial = (num) => {
console.log('Calculating factorial...');
if (num <= 0) return 1;
let result = 1;
for (let i = 1; i <= num; i++) {
result *= i;
}
return result;
};
const factorial = useMemo(() => calculateFactorial(number), [number]);
return (
<div>
<div>
<input
type="number"
value={number}
onChange={(e) => setNumber(parseInt(e.target.value))}
/>
<p>Factorial of {number} is: {factorial}</p>
</div>
<div>
<button onClick={() => setIncrementor(incrementor + 1)}>
Increment Incrementor: {incrementor}
</button>
<p>This does not re-calculate factorial!</p>
</div>
</div>
);
}
export default FactorialCalculator;
Clicking "Increment Incrementor" re-renders the page, but as number hasn't changed, calculateFactorial won't execute again. The practical advantage is that costly work only operates when necessary.
5. useCallback: Memoizing Functions
Similar to useMemo, useCallback caches a function reference rather than a calculated value. This is important when giving callbacks to child components since, in the absence of memoization, each render results in the creation of a new function, defeating the React.memo optimizations on those children.
How it Works
useCallback takes two arguments:
The function to memoize.
A dependency array.
It returns the same function reference across renders unless a dependency changes.
import React, { useState, useCallback } from 'react';
const Button = React.memo(({ onClick, children }) => {
console.log('Button component rendered');
return <button onClick={onClick}>{children}</button>;
});
function ParentComponent() {
const [count, setCount] = useState(0);
const [value, setValue] = useState('');
const handleClick = useCallback(() => {
setCount(prevCount => prevCount + 1);
}, []);
const handleChange = (e) => {
setValue(e.target.value);
};
return (
<div>
<p>Count: {count}</p>
<Button onClick={handleClick}>Increment Count</Button>
<br />
<input type="text" value={value} onChange={handleChange} placeholder="Type something..." />
<p>Input Value: {value}</p>
</div>
);
}
export default ParentComponent;
Even though the button has nothing to do with the input, without useCallback, handleClick would be a new function on each render, causing Button to re-render each time value changed. When using useCallback, handleClick remains the same reference, allowing React.memo to function as intended.
6. useContext: Accessing Context
You may directly subscribe to React's Context API within functional components by using useContext. The conventional approach to prop drilling is context, which involves sending data through numerous component layers before arriving at a deeply nested child.
How It Works
The current context value supplied by the closest matching Provider above it in the tree is returned by useContext, which accepts a Context object (returned from React.createContext).
Setting up Context
// ThemeContext.js
import React from 'react';
const ThemeContext = React.createContext('light');
export default ThemeContext;
Providing Context
// App.js
import React, { useState } from 'react';
import ThemeContext from './ThemeContext';
import Toolbar from './Toolbar';
function App() {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={theme}>
<button onClick={toggleTheme}>Toggle Theme</button>
<Toolbar />
</ThemeContext.Provider>
);
}
export default App;
Consuming Context with useContext
// ThemedButton.js
import React, { useContext } from 'react';
import ThemeContext from './ThemeContext';
function ThemedButton() {
const theme = useContext(ThemeContext);
const buttonStyle = {
background: theme === 'dark' ? '#333' : '#eee',
color: theme === 'dark' ? 'white' : 'black',
padding: '10px 20px',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
};
return (
<button style={buttonStyle}>
I am a {theme} themed button
</button>
);
}
export default ThemedButton;
Prop threading through App → Toolbar → ThemedButton is not necessary because ThemedButton reads theme directly from context. The same context can be consumed in the same way by any component anywhere in the tree.
Conclusion
Most real-world React scenarios are covered by these six hooks. The majority of daily state and side effect work is handled by useState and useEffect. An escape route to the DOM or a location to store changeable values without causing renders is provided by useRef. You'll find yourself using useMemo and useCallback more frequently as your components become more complex, which is typically an indication that your architecture is maturing rather than over-engineering. useContext completes the picture by maintaining shared state accessibility without turning your component tree into a prop-passing relay race.


