Hooks: The Great Migration

SS Saurav Sitaula

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 ComponentHooks Equivalent
constructor + this.state = {}useState()
this.setState()Setter from useState()
componentDidMountuseEffect(() => {}, [])
componentDidUpdateuseEffect(() => {}, [deps])
componentWillUnmountReturn function from useEffect
this.propsJust 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:

  1. useState - For component state
  2. useEffect - For side effects (data fetching, subscriptions, DOM mutations)
  3. useRef - For values that persist across renders without causing re-renders
  4. useCallback - For memoizing functions (performance optimization)
  5. useMemo - For memoizing expensive calculations
  6. 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.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism