Getting started with React hooks

Hooks are functions that let a plain function component hold state and reach into React’s lifecycle. That’s the whole idea. The API is small, and most of the confusion comes from two rules rather than from the hooks themselves.

useState

useState returns the current value and a setter. Call it once per piece of state.

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

One thing to know early: the setter is asynchronous. Calling setCount(count + 1) twice in the same handler increments once, because both calls read the same stale count. Pass a function instead when the new value depends on the old one: setCount((c) => c + 1).

useEffect

useEffect runs code after render. Fetching, subscriptions, timers, anything that touches the world outside the component.

import { useEffect, useState } from 'react';

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchUser = async () => {
      setLoading(true);
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      setUser(data);
      setLoading(false);
    };

    fetchUser();
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (!user) return <div>No user found</div>;

  return <div>{user.name}</div>;
}

The example above has a bug I still write by accident. Change userId twice in quick succession and the slower request can land last, so you render the wrong user. The fix is an AbortController, or a cancelled flag you check before calling setUser.

The dependency array is the other trap. Leave it out and the effect runs after every render. Leave a value out of it and the effect closes over a stale copy of that value. Install eslint-plugin-react-hooks and let it argue with you about the array. It is right more often than I am.

The two rules

Call hooks at the top level, and call them only from components or other hooks. Both exist because React matches hooks to state by call order, not by name. Put one inside an if and the order shifts between renders, and React hands your effect the wrong slot.

Custom hooks

A custom hook is just a function that calls other hooks. No registration, no base class. This is the part of the API that actually pays off, because shared stateful logic stops requiring a wrapper component.

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return width;
}

function MyComponent() {
  const width = useWindowWidth();
  return <div>Window width: {width}px</div>;
}

Note the cleanup return. Skip it and every mount adds another listener that never goes away. In development with StrictMode you get the mount twice on purpose, which is React telling you to write the cleanup.

Where hooks get messy is the effect that syncs state you didn’t need to store in the first place. Before reaching for useEffect, check whether the value can be computed during render from props and state you already have. Most of my worst hook code has been an effect keeping two pieces of state in agreement that should have been one.