Understanding React Hooks: The Mental Shift from Classes

By Odilon11 min read

Understanding React Hooks: The Mental Shift from Classes#

When hooks were announced at React Conf 2018, I watched the talk, nodded along, and then went back to writing class components for the next four months. I thought hooks were a gimmick. A syntax trick for people who did not want to learn how this works in JavaScript. Class components worked fine and I had real features to ship.

Then I was refactoring a class component that tracked user presence in a chat feature. The logic for one concern (is the user online?) was scattered across componentDidMount, componentDidUpdate, and componentWillUnmount. Three lifecycle methods for one concept. And I wanted to reuse that logic in another component.

I wrote a custom hook called usePresence in about 20 minutes. It was cleaner than anything I had written with render props or higher-order components. I felt a little stupid for waiting so long.

This post is about the mental shift that makes hooks click. Not the syntax — the syntax is easy. The hard part is unlearning how you thought about component logic before.

How Classes Worked: Lifecycle as the Primary Abstraction#

In class components, the framework gave you hooks into a component's lifecycle. Your code ran at specific moments in the component's life:

tsx
class UserProfile extends React.Component<Props, State> {
  state = { profile: null, isLoading: true, error: null };

  componentDidMount() {
    // Component appeared in the DOM — fetch initial data
    this.fetchProfile(this.props.userId);
  }

  componentDidUpdate(prevProps: Props) {
    // Something changed — check if we need to re-fetch
    if (prevProps.userId !== this.props.userId) {
      this.fetchProfile(this.props.userId);
    }
  }

  componentWillUnmount() {
    // Component is leaving — cancel any pending operations
    this.cancelPendingRequest();
  }

  async fetchProfile(userId: string) {
    try {
      this.setState({ isLoading: true, error: null });
      const profile = await api.getProfile(userId);
      this.setState({ profile, isLoading: false });
    } catch (err) {
      this.setState({ error: 'Failed to load profile', isLoading: false });
    }
  }

  render() {
    // ...
  }
}

The logic for one concern — keeping the profile in sync with the userId prop — is scattered across three lifecycle methods. The relationship between those methods is implied, not explicit.

There is also a subtle bug here that was common in class components: if userId changes while a fetch is in flight, the component will update state from the old fetch after the new fetch's results arrive.

How Hooks Work: Synchronization Instead of Lifecycle#

With hooks, the question changes. Instead of asking "what should happen when this lifecycle event fires?", you ask "what external things does this component need to stay synchronized with?"

tsx
function UserProfile({ userId }: { userId: string }) {
  const [profile, setProfile] = useState<Profile | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // This effect says: "keep profile in sync with userId"
    let cancelled = false;

    setIsLoading(true);
    setError(null);

    api.getProfile(userId).then(data => {
      if (!cancelled) {
        setProfile(data);
        setIsLoading(false);
      }
    }).catch(() => {
      if (!cancelled) {
        setError('Failed to load profile');
        setIsLoading(false);
      }
    });

    return () => {
      cancelled = true;
    };
  }, [userId]); // re-run when userId changes

  // ...render
}

The key insight: the useEffect dependency array is not a performance optimization — it is how you describe what your effect synchronizes with. The effect re-runs whenever anything in that array changes. The cleanup function runs before the next execution and when the component unmounts.

The scattered logic from three lifecycle methods is now co-located in one place. One effect, one concern: "the profile data should always reflect the current userId."

And here is the thing that sold me completely — this logic is reusable. You cannot share a class component's lifecycle method with another class component. But you can extract a hook.

Why Hooks Exist (The Reusability Problem)#

Before hooks, if two components both needed to track whether a network connection was available, you had two options:

  1. Render props (a component that accepts a function as a child)
  2. Higher-order components (a function that wraps a component and injects props)

Both work. Both produce "wrapper hell" — components nested inside components inside components, none of which appear in your code as actual components. Stack traces become unreadable. DevTools component trees become absurd. A component wrapped in four HOCs is a common sight in pre-hooks codebases.

Hooks let you extract stateful logic into a plain function with a use prefix:

tsx
function useNetworkStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    function handleOnline() { setIsOnline(true); }
    function handleOffline() { setIsOnline(false); }

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}

// Now any component can use this with one line
function SyncStatusBadge() {
  const isOnline = useNetworkStatus();
  return <span>{isOnline ? 'Synced' : 'Offline'}</span>;
}

No wrappers. No indirection. Just a function you call. After years of enterprise patterns with heavy abstraction layers, this simplicity felt almost suspicious.

The Rules of Hooks#

React enforces two rules:

1. Only call hooks at the top level. Do not call hooks inside loops, conditions, or nested functions.

2. Only call hooks from React function components or custom hooks. Not from regular JavaScript functions.

Why? Because React identifies which state belongs to which hook call by the order in which hooks are called during a render. If you call a hook inside an if statement, that order can change between renders and React will assign the wrong state to the wrong hook.

tsx
// Wrong: conditional hook call
function SearchResults({ query, showFilters }: Props) {
  const [results, setResults] = useState([]);

  // This will cause React to throw an error
  if (showFilters) {
    const [filters, setFilters] = useState(defaultFilters); // Don't do this!
  }
}

// Right: always call all hooks, use the value conditionally
function SearchResults({ query, showFilters }: Props) {
  const [results, setResults] = useState([]);
  const [filters, setFilters] = useState(defaultFilters); // Always called

  // Use the value conditionally, not the hook call
  const activeFilters = showFilters ? filters : defaultFilters;
}

The ESLint plugin eslint-plugin-react-hooks (included with CRA) enforces these rules automatically. If you are seeing the "rendered more hooks than during the previous render" error, you are breaking one of these rules.

Side by Side: Lifecycle vs. Hooks#

Here is a practical comparison. A component that tracks mouse position.

Class (lifecycle thinking):

tsx
class MouseTracker extends React.Component {
  state = { x: 0, y: 0 };

  handleMouseMove = (event: MouseEvent) => {
    this.setState({ x: event.clientX, y: event.clientY });
  };

  componentDidMount() {
    document.addEventListener('mousemove', this.handleMouseMove);
  }

  componentWillUnmount() {
    document.removeEventListener('mousemove', this.handleMouseMove);
  }

  render() {
    return <span>({this.state.x}, {this.state.y})</span>;
  }
}

Three pieces. Subscribe, unsubscribe, store. Scattered across the class. And if you want to share this? HOC or render prop.

Hooks (synchronization thinking):

"This component needs to stay synchronized with the mouse position."

tsx
function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    function handleMouseMove(event: MouseEvent) {
      setPosition({ x: event.clientX, y: event.clientY });
    }

    document.addEventListener('mousemove', handleMouseMove);
    return () => document.removeEventListener('mousemove', handleMouseMove);
  }, []);

  return position;
}

// Any component can use it
function Crosshair() {
  const { x, y } = useMousePosition();
  return <span>({x}, {y})</span>;
}

One function. Subscribe and unsubscribe together. Reusable anywhere.

Common Pitfalls#

Translating lifecycle methods one-to-one. "So useEffect with an empty array is componentDidMount..." — this framing works for simple cases and produces subtle bugs in complex ones. Stop thinking in lifecycle terms.

Fighting the dependency array. If the exhaustive-deps lint rule is flagging your useEffect, it is trying to tell you something. Do not silence it. Understand it.

Putting too much in one useEffect. Split effects by concern, just as you would split functions by responsibility.

Ignoring cleanup. Not returning a cleanup function from effects that set up subscriptions or timers is a memory leak waiting to happen.

The hardest part of switching to hooks is not the syntax. It is unlearning the lifecycle framing. For months the temptation is to ask "which lifecycle method does this correspond to?" and that question produces confusion every time. The reframe that finally worked for me: hooks are about what your component is synchronized with, not when code runs.

Once you internalize that, the dependency array stops feeling like a mystery to guess at and starts feeling like a clear description of the effect's inputs.

Over the next several posts we are going to go deep on each hook: useState, useEffect, useContext, and useReducer. With this foundation, the details should make sense rather than feeling like rules to memorize.

Understanding React Hooks: The Mental Shift from Classes | Blog