Okay, let’s be real for a second. We’ve all been there. You’ve just finished a beautiful piece of logic inside a React component. It fetches data, handles loading states, maybe even subscribes to a browser event. It’s a work of art.
Then, the new feature request lands. You need that exact same logic in another component. And then another.
So, what's the move? You could copy-paste. Hey, I’ve done it. You’ve probably done it. It gets the job done, but it feels... a little dirty, right? It works, until you find a bug and suddenly you're fixing it in three different places. You could try to wrestle it into a Higher-Order Component (HOC) or a Render Prop, but man, that can get complicated, and fast. The infamous "wrapper hell" is a very real place.
This is the exact moment where custom React Hooks stroll in, put on some sunglasses, and solve the problem with an elegance that almost feels like cheating. They're not just another feature; they're a fundamental shift in how we think about and share logic in React.
Today, we’re going to pull back the curtain on this bit of magic. We’ll go from "what even is a custom hook?" to building a whole toolbox of them that will make your future self very, very happy.
The Big Idea: What Is a Custom Hook?
At its core, a custom hook is just a JavaScript function. Seriously, that's it. No weird syntax, no special class. It's a function whose name starts with use and—this is the key—that can call other Hooks inside of it (like useState or useEffect).
Think of it like this: instead of keeping all your stateful logic—the useState calls, the useEffect side effects, the data transformations—locked inside a single component, you just pull it out into a reusable function. Any component can then use that function to get the same logic and state, without needing to know or care about the implementation details.
It’s pretty much the ultimate way to follow the Don't Repeat Yourself (DRY) principle in a React world.
A Quick Word on the Rules
Before we dive in and start building, we have to talk about the two big, non-negotiable rules for hooks. I like to think of them less as rules and more as... the laws of physics for hooks. You can’t break them without causing a weird black hole to open up in your app.
- Only Call Hooks at the Top Level: Don’t call hooks inside loops, conditions, or nested functions. React actually relies on the call order of hooks being the exact same on every single render.
- Only Call Hooks from React Functions: This just means you can call them from your components or from your own custom hooks. Not from regular old JavaScript functions.
And then there's the convention, which is just as important: your custom hook’s name must start with use. This is how React’s linter knows, "Hey, this is a hook," so it can check if you're following the rules. Seriously, just do it. useWhateverYouWant.
Alright, theory's over. Let's get our hands dirty.
Our First Custom Hook: The Humble useToggle
Every coding journey starts with a single step, and ours is one of the simplest but most satisfying hooks you can write: useToggle. I mean, how many times have you written const [isOpen, setIsOpen] = useState(false)? Dozens? Hundreds?
Let’s finally turn that pattern into a proper hook.
The goal here is to manage a simple boolean state—on or off, open or closed, true or false. We want to be able to get the current value and a function to flip it.
import { useState, useCallback } from 'react';
function useToggle(initialValue = false) {
// The core logic is still just useState!
const [value, setValue] = useState(initialValue);
// We create a memoized toggler function with useCallback.
// This is a good practice so it doesn't get recreated on every render.
const toggle = useCallback(() => {
setValue(v => !v);
}, []);
return [value, toggle];
}
Look at that. It's so small! But it's a perfect custom hook. It uses useState internally and returns the stateful value and a function to modify it, just like the real thing.
Now, let's see it in action inside a component.
// Imagine this is in your component file
function ModalExample() {
// Instead of useState, we use our shiny new hook!
const [isOpen, toggleIsOpen] = useToggle(false);
return (
<>
<button onClick={toggleIsOpen}>
{isOpen ? 'Close Modal' : 'Open Modal'}
</button>
{isOpen && (
<div className="modal">
<h2>Modal Content Here!</h2>
<p>Click the button outside to close me.</p>
</div>
)}
</>
);
}
See that? The component has no idea how the toggling happens. It just asks for the isOpen state and a toggleIsOpen function. We’ve successfully extracted that logic away. This might seem trivial, but trust me, this pattern is the foundation for everything that comes next.
Handling Side Effects: useWindowSize
Okay, toggling is cool and all. But the real power of custom React hooks shines when you start managing side effects with useEffect. A classic example is keeping track of the browser window's dimensions. You need to add an event listener, update state when it fires, and—most importantly—clean up that listener when the component unmounts.
Doing this in every component that needs the window size? Yeah, that's a nightmare. Let's hook-ify it.
Our useWindowSize hook will:
- Hold the
widthandheightin a state variable. - Use
useEffectto add aresizeevent listener to thewindowobject. - Update the state whenever the window is resized.
- Return a cleanup function from
useEffectto remove the event listener so we don't have memory leaks.
import { useState, useEffect } from 'react';
function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined,
});
useEffect(() => {
// Handler to call on window resize
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Add event listener
window.addEventListener("resize", handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
// The magic cleanup function!
// This runs when the component unmounts.
return () => window.removeEventListener("resize", handleResize);
}, []); // Empty array ensures effect is only run on mount and unmount
return windowSize;
}
And just like that, any component can become responsive with one single line of code.
function ResponsiveComponent() {
const { width, height } = useWindowSize();
if (width === undefined) {
return <div>Loading dimensions...</div>;
}
return (
<div>
<h1>My Awesome App</h1>
<p>Window Width: {width}px</p>
<p>Window Height: {height}px</p>
{width < 768 ? <p>You're on a mobile-ish screen!</p> : <p>Enjoy the desktop view!</p>}
</div>
);
}
No more messy useEffect logic cluttering up our component. No more forgotten cleanup functions causing subtle bugs. Just a clean, declarative const { width, height } = useWindowSize();. That’s a huge win.
The Workhorse: Abstracting Data Fetching with useFetch
Alright, this is the big one. Almost every single React application fetches data. And the pattern is always the same, right? You need a state for the data, a state for loading, and a state for any potential error.
Let’s build a generic useFetch hook that handles all of this for us. It'll take a URL and return that classic trifecta: data, loading, and error status.
This hook is a little more complex, but it follows the exact same principles we've been using. It's all about encapsulating the state management and the fetch side effect.
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// useEffect callbacks can't be async directly.
// So we define an async function inside.
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
// If the server responds with a 4xx or 5xx status
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
setData(json);
} catch (err) {
setError(err.message);
} finally {
// This runs whether it succeeded or failed
setLoading(false);
}
};
fetchData();
// The dependency array [url] means this effect will re-run
// if the URL prop ever changes.
}, [url]);
return { data, loading, error };
}
Heads up: A production-ready version of this hook would also handle race conditions with a cleanup function.
Now, using this in a component feels incredibly clean. Almost too easy.
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`https://api.example.com/users/${userId}`);
if (loading) {
return <div>Loading profile...</div>;
}
if (error) {
return <div>Oops, something went wrong: {error}</div>;
}
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
</div>
);
}
Just look at that component! It's pure UI logic. All the messy, stateful data-fetching stuff is completely gone, tucked away neatly inside our useFetch hook. We can reuse this hook for fetching users, products, articles—you name it. This is where you really start to feel the superpowers that custom React Hooks give you.
Solving Annoying UI Problems: useDebounce
Have you ever built a search bar that fires an API call on every single keystroke? It's slow, it's inefficient, and it absolutely hammers your backend. The classic solution is "debouncing"—waiting for the user to stop typing for a brief moment before you actually fire off the request.
Writing debounce logic can be a little tricky. It usually involves a setTimeout and a clearTimeout. You know what that sounds like? A perfect candidate for a custom hook!
Our useDebounce hook will take a value (like our search term) and a delay. It will only return the new value after the specified delay has passed without the original value changing.
import { useState, useEffect } from 'react';
function useDebounce(value, delay = 500) {
// State to store the debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// Set up a timer
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// This is the cleanup function.
// It runs every time the `value` or `delay` changes.
// It clears the *previous* timer, effectively resetting it.
return () => {
clearTimeout(handler);
};
}, [value, delay]); // Only re-call effect if value or delay changes
return debouncedValue;
}
The real magic is in that useEffect cleanup function. Every time the user types, the value changes, the effect re-runs, and the previous timeout gets cleared before a new one is set. The timeout only gets to finish and update the state if the user actually stops typing.
Let’s plug this into a search component and see the result.
function SearchBar() {
const [searchTerm, setSearchTerm] = useState('');
// Get the debounced version of the search term
const debouncedSearchTerm = useDebounce(searchTerm, 500);
// This effect will only run when the debounced value changes
useEffect(() => {
if (debouncedSearchTerm) {
console.log(`Searching for... ${debouncedSearchTerm}`);
// Here you would make your API call
}
}, [debouncedSearchTerm]);
return (
<input
type="text"
placeholder="Search for articles..."
onChange={(e) => setSearchTerm(e.target.value)}
value={searchTerm}
/>
);
}
It's flawless. Our search component is simple and totally declarative. All that complex timing logic is hidden away. You can now add debouncing to any input in your app with a single, beautiful line of code.
Mistakes I've Made (So You Don't Have To)
Building custom hooks is incredibly empowering, but it's also pretty easy to get tangled up. So, here are a few hard-won lessons from my own time in the trenches.
- Don't Make Your Hook Too "Smart": I know it's tempting to build one massive
useEverythinghook that handles fetching, state, and maybe even some UI logic. Resist that urge! A good hook does one thing and does it well.useFetchfetches data.useDebouncedebounces a value. Keep them focused and composable. - Return Objects for Clarity: For hooks that return several values (like our
useFetch), returning an object like{ data, loading, error }is often much clearer than an array[data, loading, error]. It makes the code in your component self-documenting. For very simple hooks likeuseTogglethat are meant to mimicuseState, an array is totally fine. - Remember
useCallback: If your hook returns a function (like ourtogglefunction inuseToggle), it's a great idea to wrap it inuseCallback. This prevents that function from being recreated on every single render, which can save you from triggering unnecessary re-renders in the components that use your hook. Check out ouruseToggleexample again to see this in action. For a deeper dive, our article on React performance optimization is a great resource.
Your Turn to Build
We’ve really only scratched the surface here. The true beauty of custom hooks is that they are tailored to your application's specific logic.
Got a form? Build a useForm hook to handle its state.
Interacting with localStorage a lot? useLocalStorage is a classic for a reason.
Need to know if an element has scrolled into view? useIntersectionObserver.
The pattern is always the same: find a piece of stateful logic that you find yourself repeating, pull it out into a function named useWhatever, and call the built-in hooks you need from inside it.
You'll be amazed at how quickly your components shrink and how much cleaner and more readable your entire codebase becomes. You're no longer just building UIs; you're building a library of reusable, logical Legos for your entire application.
Frequently Asked Questions
Why can't I use hooks inside an
ifstatement?It really comes down to how React keeps track of things under the hood. For each component, React maintains a list of its hooks. On every render, it expects to call those hooks in the exact same order. If you put a
useStateinside a condition, on some renders that list of hooks might be shorter or longer than the last one. React would get confused and wouldn't know which state belongs to whichuseStatecall. It would lead to some really unpredictable bugs. Sticking to the "top-level" rule ensures this order is always preserved and everything stays predictable.
What's the difference between a custom hook and just a regular helper function?
This is a great question! The key difference is that a regular JavaScript function can't use hooks. If your function needs to tap into React's state (
useState), lifecycle (useEffect), or context (useContext), it must be a custom hook (and be named with theuseprefix). If it's just a pure function that takes some input and returns some output without any state or side effects (like a function that formats a date), then it's just a regular helper function.
Are there libraries of pre-made custom hooks?
Absolutely! And you should definitely check them out. Libraries like
react-useandusehooks-tshave a massive collection of battle-tested hooks for almost any scenario you can think of. They are fantastic for learning new patterns and for saving time. That said, I always recommend building a few of your own first—it’s truly the best way to understand how they work from the inside out.