Hooks: The Great Migration
Master React Hooks with this comprehensive tutorial. Learn useState for state management, useEffect for side effects, and how to migrate from class components. Includes practical examples, common pitfalls like stale closures, and the mental model shift from lifecycle methods to hooks.
February 2019: The Announcement
I was sitting at my desk, still writing class components, still binding this in constructors, still scattering related logic across three lifecycle methods.
Then React 16.8 dropped. Hooks were stable.
The React team said: “You can now use state and other React features without writing a class.”
I was skeptical. “What’s wrong with classes? I just learned them!”
Then I tried hooks. And I never looked back.
The Old Way vs The New Way
Let me show you why hooks felt like a revelation.
State: Before and After
Class component:
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Add One
</button>
</div>
);
}
}
Function component with hooks:
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Add One
</button>
</div>
);
}
Gone: constructor, super(props), this.state, this.setState, this.
What’s left: Just the logic that matters.
Lifecycle Methods: Before and After
Remember my chat subscription example? Here’s the same thing with hooks:
Class component (3 lifecycle methods):
class ChatRoom extends React.Component {
constructor(props) {
super(props);
this.state = { messages: [] };
}
componentDidMount() {
this.subscription = ChatAPI.subscribe(this.props.roomId, this.handleMessage);
}
componentDidUpdate(prevProps) {
if (prevProps.roomId !== this.props.roomId) {
this.subscription.unsubscribe();
this.subscription = ChatAPI.subscribe(this.props.roomId, this.handleMessage);
this.setState({ messages: [] });
}
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
handleMessage = (message) => {
this.setState(state => ({ messages: [...state.messages, message] }));
}
render() {
return <MessageList messages={this.state.messages} />;
}
}
Function component with hooks (1 effect):
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const subscription = ChatAPI.subscribe(roomId, message => {
setMessages(msgs => [...msgs, message]);
});
return () => subscription.unsubscribe();
}, [roomId]);
return <MessageList messages={messages} />;
}
Everything related to the subscription is in one place. Setup, cleanup, and dependency handling all together. This was the moment I understood what the hype was about.
The Mental Model Shift
componentDidMount + componentWillUnmount = useEffect with empty deps
// Class
componentDidMount() {
document.title = 'Hello!';
}
// Hooks
useEffect(() => {
document.title = 'Hello!';
}, []); // Empty array = run once on mount
// Class
componentDidMount() {
window.addEventListener('resize', handleResize);
}
componentWillUnmount() {
window.removeEventListener('resize', handleResize);
}
// Hooks
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
componentDidUpdate = useEffect with dependencies
// Class
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.fetchUser(this.props.userId);
}
}
// Hooks
useEffect(() => {
fetchUser(userId);
}, [userId]); // Runs when userId changes
componentDidMount + componentDidUpdate = useEffect
// Class (runs on mount AND updates)
componentDidMount() {
document.title = `Count: ${this.state.count}`;
}
componentDidUpdate() {
document.title = `Count: ${this.state.count}`;
}
// Hooks
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]); // Runs on mount and when count changes
// Or for EVERY render:
useEffect(() => {
document.title = `Count: ${count}`;
}); // No dependency array = run every render
The Migration Cheatsheet
Here’s what I pinned above my monitor during the migration:
| Class Component | Hooks Equivalent |
|---|---|
constructor + this.state = {} | useState() |
this.setState() | Setter from useState() |
componentDidMount | useEffect(() => {}, []) |
componentDidUpdate | useEffect(() => {}, [deps]) |
componentWillUnmount | Return function from useEffect |
this.props | Just props (function argument) |
Instance variables (this.x) | useRef() |
Things That Tripped Me Up
1. The Stale Closure Problem
This got me for DAYS:
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log(count); // Always logs 0!
setCount(count + 1); // Always sets to 1!
}, 1000);
return () => clearInterval(interval);
}, []); // Empty deps = count is "captured" at 0
return <div>{count}</div>;
}
The count inside the interval is stuck at its initial value. The fix:
setCount(c => c + 1); // Use the function form
2. useEffect Runs After Paint
In class components, componentDidMount and componentDidUpdate run synchronously after React updates the DOM but before the browser paints.
useEffect runs after the paint. This is usually what you want (better performance), but occasionally you need the synchronous behavior. That’s what useLayoutEffect is for.
3. The Dependency Array is Not Optional (Mentally)
Technically you can omit the dependency array. Practically, you almost never should.
// 😰 Runs every single render
useEffect(() => {
fetchData();
});
// 😌 Runs only when id changes
useEffect(() => {
fetchData(id);
}, [id]);
The ESLint plugin eslint-plugin-react-hooks will save you. Install it. Trust it.
4. Multiple useEffects are Encouraged
In class components, you’d put everything in componentDidMount. With hooks, split by concern:
function UserDashboard({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
// Effect 1: Fetch user
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
// Effect 2: Fetch posts (separate concern)
useEffect(() => {
fetchPosts(userId).then(setPosts);
}, [userId]);
// Effect 3: Update document title (separate concern)
useEffect(() => {
if (user) {
document.title = `${user.name}'s Dashboard`;
}
}, [user]);
// ...
}
Each effect handles one thing. Much easier to reason about.
Custom Hooks: The Real Magic
The biggest win wasn’t useState or useEffect individually. It was the ability to create custom hooks that extract and share stateful logic.
// A custom hook for window size
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
// Use it anywhere!
function MyComponent() {
const { width, height } = useWindowSize();
return <div>Window: {width} x {height}</div>;
}
function AnotherComponent() {
const { width } = useWindowSize();
return <div>Width: {width}</div>;
}
No HOCs. No render props. No wrapper hell. Just a function that starts with use.
The Hooks I Use Most
After a few months, these became my daily drivers:
- useState - For component state
- useEffect - For side effects (data fetching, subscriptions, DOM mutations)
- useRef - For values that persist across renders without causing re-renders
- useCallback - For memoizing functions (performance optimization)
- useMemo - For memoizing expensive calculations
- useContext - For consuming React context
There are more (useReducer, useLayoutEffect, useImperativeHandle), but these six cover 95% of my use cases.
Should You Migrate Old Code?
The React team’s advice: Don’t rewrite working class components just because hooks exist.
My advice: For new code, use hooks. For old code, migrate when you’re already making changes to that component.
Hooks and classes can coexist in the same app. There’s no rush.
The Journey Continues
Hooks fundamentally changed how I think about React. Instead of thinking in terms of lifecycle moments (“when does this mount?”), I think in terms of synchronization (“what should this effect stay in sync with?”).
It took a few weeks to fully internalize, but now I can’t imagine going back. The code is cleaner, the logic is colocated, and the this keyword is a distant memory.
P.S. — If you’re starting React today, you might never write a class component. Lucky you. But if you ever maintain legacy code, you’ll thank yourself for understanding the lifecycle methods that hooks replaced. Everything in programming is a response to what came before.
Saurav Sitaula
Software Architect • Nepal