Let's take a quick trip back in time, shall we? Not too far, just to the ancient, pre-2019 era of React development. Do you remember class components? The constructor, the super(props), binding this in what felt like every other method? And oh, the lifecycle methods—componentDidMount, componentWillUpdate—it sometimes felt like you were performing a sacred ritual just to fetch some data.
Yeah. Me too. It was powerful, for sure, but let’s be honest—it was also kinda clunky.
Then, at React Conf 2018, the React team dropped a bombshell that changed everything: Hooks. It felt like a massive breath of fresh air. Suddenly, our simple, beautiful functional components could have state. They could have lifecycles. They could have... well, power. This wasn't just a new feature; it was a whole new way of thinking.
So, this is my friendly, no-nonsense React Hooks guide. We're going to demystify these little functions and show you how they let you write cleaner, more intuitive, and more powerful React code. We’ll cover the absolute essentials (useState, useEffect), some handy helpers (useContext, useRef), and then we'll get to the really fun part: creating your own custom Hooks.
Ready? Let's dive in.
So, What's the Big Deal with Hooks Anyway?
Before we jump into the code, let’s talk for a second about the why. The textbook definition is that Hooks are functions that let you "hook into" React state and lifecycle features from function components.
But what that really means for us, day-to-day, is that you can stop writing classes. For good.
This solved a few big headaches that we all secretly (or not-so-secretly) hated:
- The
thiskeyword: A constant source of confusion. Is it bound correctly? Is it not? Who even knows! Hooks get rid ofthisentirely. - Wrapper Hell: Remember Higher-Order Components and Render Props? They were clever patterns, but they often led to a deeply nested component tree that was a nightmare to debug.
- Scattered Logic: In a class component, the logic for fetching data might be split between
componentDidMountandcomponentDidUpdate, while the cleanup logic was way over incomponentWillUnmount. Hooks let us group related code together. It just makes sense.
The Two Golden Rules of Hooks
Before you write a single Hook, you need to get these two rules locked in. The React team is serious about them, and for good reason—they're what ensure Hooks work predictably.
- Only Call Hooks at the Top Level. This means don't call them inside loops, conditions, or nested functions. React relies on the call order of Hooks being the same on every single render.
- Only Call Hooks from React Functions. This means either from a React functional component or from a custom Hook you've written. Don't try to call them from regular old JavaScript functions.
If you ever forget, the ESLint plugin will probably yell at you. Trust me on this, it's a helpful friend to have.
useState: The Heartbeat of Your Component
This is the one you'll be using constantly. useState is how you give your component a memory.
Before Hooks, a functional component was basically a "dumb" renderer. It took in props, returned some JSX, and immediately forgot everything. With useState, it can actually remember things between renders.
Let's look at the classic counter example. It's a classic for a reason!
import { useState } from 'react';
function Counter() {
// Here's the magic!
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
So what's happening here?
- We call
useState(0), passing in the initial state (in this case, the number 0) as an argument. - It gives us back an array with exactly two things: the current state value (which we've called
count) and a function to update it (which we've calledsetCount). - We're using that cool array destructuring syntax to give them nice, readable names.
When you call setCount(count + 1), you're basically telling React, "Hey, this component's state has changed. You should probably re-render it with the new value." And React dutifully does just that.
Working with Objects and Arrays in State
Okay, this is a spot where beginners often trip up, so let's be super clear. When your state is an object or an array, you must not mutate it directly.
Here's why: React only re-renders when it sees a new object or array reference. If you just change a property on the existing object, React will shrug and say, "Looks like the same object to me," and your UI won't update. It's a bit stubborn like that.
Here’s the wrong way vs. the right way to handle it.
function UserProfile() {
const [user, setUser] = useState({ name: 'Alex', age: 28 });
const handleNameChange = () => {
// ❌ WRONG! Don't do this. You're mutating the original object.
// user.name = 'Samantha';
// setUser(user); // React won't see a change!
// ✅ RIGHT! Create a new object using the spread syntax.
setUser({ ...user, name: 'Samantha' });
};
return (
<div>
<p>Name: {user.name}, Age: {user.age}</p>
<button onClick={handleNameChange}>Change Name</button>
</div>
);
}
The spread syntax (...user) is your absolute best friend here. It creates a shallow copy of the old object, and then we just overwrite the name property. It's a brand new object, so React knows it's time to get to work and re-render.
useEffect: Taming the Wild World of Side Effects
State is great, but our apps do more than just manage state, right? What about fetching data from an API, setting up a subscription, or manually fiddling with the DOM? That's where side effects come in, and useEffect is our tool for managing them.
If you're coming from the world of classes, you can think of useEffect as a combination of componentDidMount, componentDidUpdate, and componentWillUnmount all rolled into one beautiful, concise API.
The basic idea is this: "Run this piece of code after the component renders."
import { useState, useEffect } from 'react';
function DocumentTitleChanger() {
const [count, setCount] = useState(0);
// This effect will run after every single render
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<button onClick={() => setCount(count + 1)}>
Click to update title ({count})
</button>
);
}
Simple, right? Every time count changes and the component re-renders, our effect runs again, and the document title gets a fresh update.
The All-Important Dependency Array
But hang on—running an effect on every single render can be inefficient, or worse, it can cause nasty infinite loops. What if we only want to run our effect once, when the component first appears? Or only when a specific value changes?
This is where the second argument to useEffect comes in: the dependency array.
I like to think of it as a bouncer at a club. It tells React when to let the effect run.
-
No dependency array:
useEffect(() => { ... })- The bouncer is on a coffee break. The effect runs after every single render. You should use this sparingly.
-
Empty dependency array:
useEffect(() => { ... }, [])- The bouncer is super strict. The effect runs only once, right after the initial render. This is perfect for one-time setup tasks like fetching initial data.
-
Array with values:
useEffect(() => { ... }, [propA, stateB])- The bouncer has a specific guest list. The effect runs once on mount, and then it will only run again if
propAorstateBhas changed since the last time it ran.
- The bouncer has a specific guest list. The effect runs once on mount, and then it will only run again if
Let's look at a classic data-fetching example to see it in action.
import { useState, useEffect } from 'react';
function UserData({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(error => console.error("Failed to fetch user", error));
}, [userId]); // <-- The dependency array!
if (loading) return <p>Loading...</p>;
if (!user) return <p>No user found.</p>;
return <h1>{user.name}</h1>;
}
See that? The effect depends on userId. If the userId prop changes, React knows it needs to re-run the effect to fetch the new user's data. But if some other state in the component changes and userId stays the same, the effect won't run again. It's incredibly efficient.
The Cleanup Function
So what if your effect sets up something that needs to be torn down later, like a subscription or a setInterval timer? If you don't clean those up, you'll end up with memory leaks, which are no fun at all.
useEffect has a beautiful solution for this. Just return a function from your effect, and React promises to run it right before the component unmounts (or before the effect is about to run again).
useEffect(() => {
const timerId = setInterval(() => {
console.log('Tick');
}, 1000);
// This is the cleanup function
return () => {
console.log('Cleaning up the timer!');
clearInterval(timerId);
};
}, []); // Run once, clean up on unmount
useContext: Escaping Prop-Drilling Hell
We've all been there. You have some data at the very top of your app—maybe the current user's info or the active theme—and you need to get it to a component that's nested five levels deep. So you pass it down, prop by prop, through a chain of components that don't even care about it. That's prop drilling, and honestly, it's a pain.
useContext is the hero that saves us from this. It lets you create a sort of global state that any component in the tree below it can access without needing props.
It’s a simple three-step dance:
- Create a Context: Make a new context object using
createContext. - Provide the Context: Wrap your component tree (or just a part of it) in a
Providercomponent and give it avalue. - Consume the Context: In any child component that needs the data, just call the
useContexthook to read the value.
Here's a simple theme switcher example that makes it crystal clear.
import React, { useState, useContext, createContext } from 'react';
// 1. Create the Context
const ThemeContext = createContext();
// A helper custom hook (we'll talk more about this later!)
function useTheme() {
return useContext(ThemeContext);
}
// 2. Provide the Context
export function App() {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
// The value can be anything: a string, an object, a function...
const value = { theme, toggleTheme };
return (
<ThemeContext.Provider value={value}>
<Layout />
</ThemeContext.Provider>
);
}
// A component in the middle that doesn't care about the theme
function Layout() {
return <Toolbar />;
}
// 3. Consume the Context
function Toolbar() {
const { theme, toggleTheme } = useTheme();
const style = {
background: theme === 'light' ? '#eee' : '#333',
color: theme === 'light' ? '#333' : '#eee',
padding: '1rem',
};
return (
<div style={style}>
<p>The current theme is {theme}</p>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
);
}
Look at that! The Layout component didn't need a theme prop passed to it at all. Toolbar just reached up and grabbed the theme data directly from the context. It's so much cleaner. For more on this, you should check out our deep dive into the React Context API.
useRef: Your Component's Secret Backpack
useRef is a bit of a chameleon. It has two main jobs that seem unrelated at first, but they're really two sides of the same coin: persisting values without causing re-renders.
Use Case 1: Accessing DOM Elements
Sometimes you just need to talk directly to a DOM element—to focus an input field, measure its size, or trigger an animation. useRef gives you a direct line, like an escape hatch to the underlying DOM.
import { useRef, useEffect } from 'react';
function TextInputWithFocusButton() {
const inputEl = useRef(null);
useEffect(() => {
// On component mount, let's focus the input
inputEl.current.focus();
}, []);
return (
<input ref={inputEl} type="text" />
);
}
All we do is create a ref, attach it to the <input> element with the special ref attribute, and from then on, inputEl.current points directly to that DOM node. We can call any method on it, like .focus(), just like in plain JavaScript.
Use Case 2: Storing a Mutable Value
This is the more subtle, but equally powerful, use case. A ref is like an instance variable you might have used in a class. It's a "box" where you can keep a value that persists across renders, but—and this is the absolute key difference from useState—changing a ref does not trigger a re-render.
This makes it perfect for things like timer IDs, subscription objects, or any other piece of data you need to track "behind the scenes" without affecting what the user sees.
import { useState, useRef, useEffect } from 'react';
function Stopwatch() {
const [time, setTime] = useState(0);
const intervalRef = useRef(null);
const start = () => {
if (intervalRef.current !== null) return; // Already running
intervalRef.current = setInterval(() => {
setTime(prevTime => prevTime + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
// Clean up on unmount, just in case
useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);
return (
<div>
<p>Time: {time}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
Here, we're storing the setInterval ID in intervalRef.current. When we update it, the component doesn't re-render. It’s just a secret little piece of information we've tucked away for us to use later.
The Ultimate Power-Up: Custom Hooks
Okay, this is where it all clicks. This is the moment you go from being a React user to a true React developer.
A custom Hook is just a JavaScript function whose name starts with use and that can call other Hooks. That’s it. That's the whole definition. But this simple idea is incredibly powerful because it lets you extract component logic into reusable functions.
Any time you find yourself writing the same stateful logic in multiple components, that's a giant neon sign screaming, "MAKE ME A CUSTOM HOOK!"
Let's build a classic one: useLocalStorage. It will behave just like useState, but it will also magically sync its value to the browser's local storage.
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
// Get the initial value from localStorage or use the provided initialValue
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.log(error);
return initialValue;
}
});
// This effect updates localStorage whenever the state changes
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.log(error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
// --- And here's how you use it! ---
function SettingsForm() {
const [name, setName] = useLocalStorage('username', 'Guest');
return (
<div>
<label>
Name:
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
/>
</label>
<h3>Hello, {name}!</h3>
</div>
);
}
Isn't that beautiful? All the messy logic of reading from and writing to local storage is completely hidden away. Our SettingsForm component just uses it like a normal useState Hook and has no idea about the implementation details. It's clean, reusable, and so much easier to test.
Frequently Asked Questions
Can I use React Hooks in class components?
Nope! Hooks are a feature designed specifically for functional components. You can't call a Hook from inside a class. However, you can absolutely have a mix of class and functional components living happily together in the same application while you're migrating.
What's the difference between
useStateanduseRef?This is a great question. The key difference is that updating state with
useState's setter function will cause your component to re-render. Mutating auseRef's.currentproperty will not cause a re-render. The rule of thumb is: useuseStatefor anything that the user should see change on the screen. UseuseReffor tracking values behind the scenes or for accessing DOM elements.
When should I create a custom hook?
The best time is when you notice you've copied and pasted the same stateful logic (anything involving
useState,useEffect, etc.) into two or more components. That's your signal to extract that logic into a reusableuseMyAwesomeLogichook.
Wrapping It Up
Phew, we've covered a lot of ground. From the basic memory of useState to the side-effect management of useEffect, from escaping prop-drilling hell with useContext to the secret storage of useRef. And finally, we saw how you can bundle all that power into your own custom Hooks.
Hooks fundamentally changed React for the better. They encourage us to write smaller, more focused components and make it a genuine joy to share logic. If you're still new to them, my best advice is this: just start building. Make a counter. Fetch some data from an API. Build a form. The muscle memory will kick in faster than you think.
And if you're looking for where to go next, I'd suggest diving deeper into building production-ready apps with a framework like Next.js. You can check out our Next.js Basics guide to get started.
Happy coding