Skip to main content
Back to Journal
ReactJavaScript

React Hooks Changed How I Think About State

React 16.8 dropped last week with official support for Hooks, and I have been spending every spare hour playing with them. Coming from a background of class components with lifecycle methods, this feels like a completely different way of thinking about React. And I mean that in the best way.

useState: State Without Classes

The simplest hook, and the one that immediately sold me. Instead of writing a class with a constructor and this.setState, you call useState and get back a value and a setter.

import React, { useState } from 'react';

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

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

Compare that to the class version:

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.increment = this.increment.bind(this);
  }

  increment() {
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.increment}>
          Increment
        </button>
      </div>
    );
  }
}

The hooks version is half the code, and there is no this binding confusion. That alone would be enough to convince me, but it gets better.

useEffect: Side Effects Done Right

In class components, side effects are split across lifecycle methods. You fetch data in componentDidMount, clean up subscriptions in componentWillUnmount, and handle prop changes in componentDidUpdate. Related logic ends up scattered across three different methods.

useEffect consolidates all of that into one place.

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch('/api/users/' + userId)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  return <h1>{user.name}</h1>;
}

The dependency array [userId] tells React to re-run the effect whenever userId changes. No more comparing prevProps in componentDidUpdate. No more keeping track of which props you need to watch.

For cleanup (like unsubscribing from a WebSocket or clearing a timer), you return a function from useEffect:

useEffect(() => {
  const timer = setInterval(() => {
    setSeconds(s => s + 1);
  }, 1000);

  return () => clearInterval(timer);
}, []);

The empty dependency array [] means this effect runs once on mount, and the cleanup runs on unmount. The setup and teardown are right next to each other. In class components, they would be in two separate lifecycle methods.

Custom Hooks: Reusable Logic

This is where hooks really shine. Before hooks, sharing stateful logic between components required patterns like render props or higher-order components. Both work, but both add complexity and make the component tree harder to follow.

Custom hooks are just functions that use other hooks. The naming convention is to start with "use".

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Usage in any component:
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
  // ...
}

The useLocalStorage hook encapsulates the logic of reading from localStorage on mount and writing back on changes. Any component can use it without knowing anything about the implementation. No wrapper components, no prop drilling, no render prop callbacks.

Here is another one I wrote for API calls:

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    setError(null);
    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error('Request failed');
        return res.json();
      })
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);

  return { data, loading, error };
}

The Rules

Hooks come with two rules that you need to follow. First, only call hooks at the top level of your function. Never inside loops, conditions, or nested functions. This is because React relies on the order of hook calls to keep track of state between renders.

Second, only call hooks from React function components or custom hooks. Do not call them from regular JavaScript functions.

The ESLint plugin eslint-plugin-react-hooks enforces both rules automatically. Install it and turn it on. It will save you from subtle bugs.

useRef for Mutable Values

One more hook worth mentioning: useRef. It gives you a mutable container that persists across renders without triggering a re-render when it changes. It is commonly used for DOM references, but it is also great for storing any mutable value that you need to persist.

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null);

  const start = () => {
    intervalRef.current = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
  };

  const stop = () => {
    clearInterval(intervalRef.current);
  };

  return (
    <div>
      <p>{seconds} seconds</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

My Take

I am fully converted. Every new component I write is a function component with hooks. The code is shorter, the logic is better organized, and custom hooks make sharing behavior between components trivially easy.

The one thing I would caution is that useEffect takes some time to understand properly. The dependency array behavior, the cleanup function, and the timing of when effects run are all slightly different from lifecycle methods. Spend time reading the official docs on this. It is worth getting right.

But overall, hooks feel like React grew up. The patterns are cleaner, the code is more readable, and the component model makes more sense. I am excited to see where this goes.

reacthooksuseStateuseEffectfunctional-components