Introduction
In this article, we will discuss what a React hook is, the different types of React hooks, and how we can easily use them. Let's start.
What are Hooks in React?
Hooks are just functions that let you “hook into” React’s features in functional components. In other terms, Hooks are built-in functions that let you use state, side effects, and other React features inside functional components without using classes.
Before Hooks, we used class components for things like state, lifecycle methods, and side effects. Now we use functions for everything and this is more cleaner, shorter, easier to manage for us.
Built-in React Hooks
There are a few built-in Hooks you’ll use all the time, like:-
- useState for state
- useEffect for side effects (like fetching data)
- useRef for persisting values without re-renders
- useContext for accessing shared state
- useReducer if things get a bit more complex
- useCallback for memoizing functions to prevent unnecessary re-renders in children
- useMemo for memoizing values to avoid expensive recalculations
useState. For Managing State in a Functional Component
useState Hook allows us to track the state in a function component. State generally refers to data or properties that need to be tracked in an application.
You’ll use this a lot. Like every form, every toggle, every interactive bit, this is where it starts.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
The count is {count}
</button>
);
}
Here, useState(0) sets the initial value. It returns an array: the current value and a function to update it.
State updates trigger re-renders.
When to use it?
Whenever you want the UI to reflect some changing value, user input, toggles, tab switches, etc.
useEffect. Run Code When Something Happens
useEffect is a React Hook that lets you run some code after the component has been rendered.
This is like saying, “Hey React, after you render this, I want to do something.”
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
async function fetchUser() {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
}
fetchUser();
}, [userId]); //run this whenever userId changes
if (!user) return <p>Loading..</p>;
return <div>{user.name}</div>;
}
Here, this React component, UserProfile, fetches user data from an API whenever the userId prop changes, using the useEffect hook. It displays "Loading.." while the data is being fetched and shows the user's name once the data is loaded.
Here, the dependency array ([userId]) is key. If it’s empty, the effect runs once on mount. If it has values, the effect runs whenever those values change. If you forget to add dependencies, you'll get stale or buggy results. If you ever fetch some data, but it doesn't update when props change? Yeah, probably a missing dependency.
Why do we need it?
React doesn’t wait for async functions before rendering. So if you want to fetch something, set a timeout, add an event listener all that goes in useEffect.
useRef. Like a Box That Doesn’t Cause Re-renders
This one’s a bit confusing at first. But the idea is: you want to persist a value without triggering a re-render.
import { useRef, useEffect } from 'react';
function Timer() {
const intervalRef = useRef(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
console.log('tick');
}, 1000);
return () => clearInterval(intervalRef.current);
}, []);
return <p>Check the console</p>;
}
Here, this Timer component sets up a setInterval to log "tick" every second when mounted. It also clears the interval when the component unmounts using useRef to persist the interval ID.
useRef gives you a mutable .current object. Unlike state, changing .current doesn’t trigger a re-render. It is great for timers, DOM elements, or storing previous values.
useContext. Skip Prop Drilling
useContext is a React Hook that lets you share data between components without passing props manually at every level.
Let’s say you’ve got a theme, user data, or some global config you don’t want to pass down through every component manually. That’s where context and useContext shine.
const ThemeContext = React.createContext('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
return <ThemeButton />;
}
function ThemeButton() {
const theme = useContext(ThemeContext);
return <button className={theme}>click me</button>;
}
Here, this React code uses ThemeContext to provide a "dark" theme value to ThemeButton via context, allowing it to style the button with the "dark" class. useContext(ThemeContext) inside ThemeButton accesses this value without prop drilling.
The context stores shared data. useContext pulls it in wherever you need it, no prop drilling required.
Why do we need it?
Things like auth user, language, theme, or even feature flags.
useReducer. For Complex State Logic
The useReducer Hook manages complex state logic by letting you update state based on actions and a reducer function, offering more control than useState. So, if useState starts feeling messy like if you’re updating multiple related values or handling actions, then you should use useReducer.
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
</>
);
}
Here, this React code uses a reducer to manage a counter's state, updating the count based on "increment" or "decrement" actions. The Counter component displays the current count and has buttons to increase or decrease it by dispatching the respective actions.
useCallback. When You Want to “Lock” a Function
The useCallback Hook only runs when one of its dependencies updates.
In React, every function you declare inside a component gets re-created on every render. That’s normally fine, but sometimes it causes unnecessary re-renders in child components if you’re passing functions as props. And here is when useCallback helps us. useCallback is basically saying -Hey React, give me the same function instance unless my dependencies change.
Example
import { useState, useCallback } from 'react';
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log('clicked');
}, []); // won't change unless dependencies change
return (
<>
<Child onClick={handleClick} />
<button onClick={() => setCount(count + 1)}>Increment</button>
</>
);
}
function Child({ onClick }) {
console.log('Child rendered');
return <button onClick={onClick}>click me</button>;
}
Here, the Parent component maintains a count state and passes a memoized handleClick function to the Child using useCallback. The Child component re-renders whenever the Parent renders, even though handleClick is memoized. Clicking the "Increment" button updates the count, causing Parent (and thus Child) to re-render and log "Child rendered" again.
Without useCallback, every render of Parent would create a new handleClick function, which would make Child re-render even if nothing else changed. So, this hook helps with performance optimization and avoiding unnecessary renders.
useMemo. For “Locking In” Expensive Calculations
useMemo memoizes a computed value to avoid unnecessary recalculations on re-renders. It only recomputes when one of the dependencies (a, b) changes. It improves performance, especially with expensive computations in render logic.
The useMemo and useCallback Hooks are similar in behavior, but they serve different purposes: useMemo returns a memoized value, while useCallback returns a memoized function.
import { useState, useMemo } from 'react';
function ExpensiveComponent({ input }) {
const [count, setCount] = useState(0);
const processedValue = useMemo(() => {
console.log('Calculating...');
return input * 1000; // suppose this takes time
}, [input]);
return (
<>
<p>Processed: {processedValue}</p>
<button onClick={() => setCount(count + 1)}>Re-render</button>
</>
);
}
Here, we use useMemo to memoize an expensive calculation (input * 1000) so it's only recalculated when input changes. This prevents unnecessary recalculations on every re-render triggered by state updates like count. Now, even if you click the button and the component re-renders, that calculation won’t run again unless the input changes.
Note. Avoid using useCallback or useMemo always, as React is already optimized for performance in most scenarios. These hooks should be treated as performance optimizations, not as default tools for every component. You should consider using them only when you've identified a performance bottleneck through profiling, when you're passing stable props to components wrapped with React.memo, or when you're performing CPU-intensive calculations that you want to avoid recomputing unnecessarily.
Conclusion
Hooks let you write clean, readable components with all the power you used to get from classes but without the boilerplate.
Here’s what to remember - useState: for local state, useEffect: for side effects and async work, useRef: for persisting values and DOM access, useContext: for global/shared values, useReducer: for state logic that needs more structure, useCallback: for memoizing functions to prevent unnecessary re-renders in children, useMemo: for memoizing values to avoid expensive recalculations.
You can also create custom hooks, but let's not go there and keep things simple for now.
Hope this was helpful.