If you’ve ever tried to manage state, lifecycle, or side effects in a React application, you’ve likely touched React Hooks. They were introduced to simplify our code, making it more readable, reusable and testable. But while they solve a lot of problems, they also open the door to subtle bugs and patterns that don’t scale well if misunderstood.
The difference between a junior and a senior developer’s use of hooks isn’t just knowing they exist; it’s understanding when and how to apply them for long-term code health.
In simple terms, React Hooks are functions that let you “hook into” React state and lifecycle features from function components. Before hooks, you had to use class components to manage state and side effects, which often led to verbose code and made it difficult to share stateful logic between components.
Hooks brought a breath of fresh air, allowing us to build an entire application with just function components. The most common hooks you’ll encounter are:
useState
: For managing local component state.useEffect
: For handling side effects like API calls, subscriptions, or manual DOM manipulation.useContext
: For providing state to a deeply nested component without prop drilling.useRef
: For accessing DOM elements or holding a mutable value that doesn’t trigger a re-render.Hooks are not just a nice-to-have feature; they are the new mental model for writing clean, composable React applications. They encourage composition over inheritance, allowing you to build complex features by combining simple, single-purpose components and custom hooks.
By abstracting repetitive logic into reusable custom hooks, you can significantly reduce boilerplate. For example, a custom useFetch
hook can be created once and then used across multiple components that need to fetch data, making the consuming component’s code cleaner and more focused on its presentation logic.
Let’s dive into some of the most fundamental hooks and a common pitfall to watch out for.
This is your go-to hook for managing state within a component.
function DarkModeToggle() {
const [isDarkMode, setIsDarkMode] = useState(false);
return (
<button onClick={() => setIsDarkMode(!isDarkMode)}>
{isDarkMode ? 'Light Mode' : 'Dark Mode'}
</button>
);
}
This is arguably the most powerful and often misunderstood hook. It’s used for any “side effect” - something that happens outside of the component’s rendering cycle.
A classic example is fetching data from an API:
function PostList() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('https://api.example.com/posts')
.then(response => response.json())
.then(data => setPosts(data));
}, []); // The empty dependency array means this runs only once on mount
return (
// ...render list of posts
);
}
The Pitfall: The dependency array. Forgetting to include a dependency can lead to stale data. Conversely, including a primitive value that changes on every render (like an object or a function) can cause an infinite loop. Always remember to ask yourself, “What values from my component scope does this effect depend on?“
Replaces prop drilling with context.
const theme = useContext(ThemeContext);
return <div className={theme}>Hello</div>;
Keeps a stable reference across renders. Useful for DOM elements or values you don’t want to trigger re-renders.
const inputRef = useRef<HTMLInputElement>(null);
For more complex apps, you’ll often reach for these:
useReducer
: When your state logic becomes more complex than a simple boolean or string, useReducer
can be a great alternative to multiple useState
calls. It’s ideal for managing state that has transitions between multiple states (like a complex form or a state machine).useMemo
& useCallback
: These hooks are for performance optimisation. They memoise values and functions, respectively, to prevent unnecessary re-renders. A common anti-pattern is using them everywhere; they can actually hurt performance if not used wisely. Use them only when you have a noticeable performance issue that needs fixing.From my experience, here are some common mistakes I see developers make with hooks:
useEffect
: It’s common to see useEffect
used to “sync” two pieces of state. Instead, try to derive state from existing state whenever possible. If B
depends on A
, calculate B
from A
directly rather than using a useEffect
.useEffect
. This prevents memory leaks and subtle bugs.useState
and useEffect
are only used for one component, keep them in that component. Don’t move them into a generic file “just in case.”A perfect example of a custom hook is one that debounces a value, like user input in a search bar. This prevents us from making an API call on every keystroke.
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debounced;
}
This hook is simple, has a single purpose, and can be easily tested. Now, our search component is much cleaner:
const SearchInput = () => {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 500);
// Now you can safely make an API call with `debouncedSearchTerm`
useEffect(() => {
if (debouncedSearchTerm) {
// make API call here
}
}, [debouncedSearchTerm]);
return (
<input type="text" placeholder="Search..." onChange={(e) => setSearchTerm(e.target.value)} />
);
};
useState
, useEffect
) before jumping into advanced hooks.Hooks are more than a React feature, they’re the core mental model of modern React development. When used well, they lead to clean, composable code and better team collaboration. But when overused or abstracted too early, they create headaches.
The key is balance: let patterns emerge naturally, focus on readability, and resist the temptation to be “too clever.” That’s how you make hooks work for you, not against you.
Subscribe to get updates on new blog posts, useful frontend tips and ideas you can apply in your own work.