DEV Community

Timevolt
Timevolt

Posted on

Hooked on React: A useState & useEffect Adventure (Like Leveling Up in Zelda)

The Quest Begins (The "Why")

Honestly, I remember the first time I tried to build a simple counter in React using a class component. I had a state object, a clunky this.setState call, and then I needed to fetch some data when the component mounted. I slapped a componentDidMount method in there, added a cleanup in componentWillUnmount, and somehow ended up with a weird bug where the count would jump two steps every time I clicked. I spent three hours staring at the console, wondering why my effect ran twice and why my state felt stale. It felt like I was fighting a final boss with a broken sword—lots of effort, little payoff.

That frustration is what pushed me to look at hooks. I’d heard the buzz, but I wasn’t convinced they’d actually make my life easier. I thought, “Surely this is just another trend that’ll fade.” Little did I know, I was about to discover a spell that would turn my clunky class incantations into elegant, functional incantations.

The Revelation (The Insight)

The big “aha!” moment came when I realized that useState and useEffect aren’t just new APIs—they’re a different way of thinking about state and side effects. With useState, you get a pair: the current state value and a setter function. No more this.state or this.setState. With useEffect, you tell React “run this after render” and optionally clean up when the component unmounts or before the effect runs again. The dependency array is the secret sauce: it tells React exactly when to re‑run the effect, preventing those pesky infinite loops.

It’s like swapping out a heavy, rickety backpack for a lightweight, modular utility belt. You only carry what you need for each quest, and you can swap out pouches (custom hooks) without rebuilding the whole pack.

Wielding the Power (Code & Examples)

Before: The Class Component Struggle

import React, { Component } from 'react';

class CounterWithFetch extends Component {
  state = {
    count: 0,
    data: null,
    loading: false,
  };

  componentDidMount() {
    this.fetchData();
  }

  componentDidUpdate(prevProps, prevState) {
    if (prevState.count !== this.state.count) {
      this.fetchData();
    }
  }

  componentWillUnmount() {
    if (this.abortController) {
      this.abortController.abort();
    }
  }

  fetchData = () => {
    if (this.abortController) {
      this.abortController.abort();
    }
    this.abortController = new AbortController();
    this.setState({ loading: true });
    fetch('https://api.example.com/data', { signal: this.abortController.signal })
      .then(res => res.json())
      .then(data => this.setState({ data, loading: false }))
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('Fetch failed:', err);
          this.setState({ loading: false });
        }
      });
  };

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Increment
        </button>
        {this.state.loading && <p>Loading…</p>}
        {!this.state.loading && this.state.data && (
          <pre>{JSON.stringify(this.state.data, null, 2)}</pre>
        )}
      </div>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Look at all that lifecycle noise! Keeping track of abortController to prevent race conditions, duplicating the fetch logic in componentDidMount and componentDidUpdate, and hoping I didn’t miss a cleanup. It works, but it’s easy to slip up—like forgetting to add the count check in componentDidUpdate and ending up with an infinite fetch loop.

After: The Hook‑Powered Victory

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

function CounterWithFetch() {
  const [count, setCount] = useState(0);
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // This effect runs whenever `count` changes
    let abortController = new AbortController();
    setLoading(true);
    fetch('https://api.example.com/data', { signal: abortController.signal })
      .then(res => res.json())
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('Fetch failed:', err);
        }
      })
      .finally(() => setLoading(false));

    // Cleanup function – runs before the next effect or on unmount
    return () => abortController.abort();
  }, [count]); // <-- Dependency array: re‑run only when count changes

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      {loading && <p>Loading…</p>}
      {!loading && data && (
        <pre>{JSON.stringify(data, null, 2)}</pre>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Whoa—what a difference! The state is declared with useState, giving us [value, setter] pairs. The side effect lives in a single useEffect. The dependency array [count] tells React: “Hey, re‑run this effect only when count changes.” The cleanup function aborts any ongoing fetch before we start a new one, so we never get stale data. No more duplicating logic across three lifecycle methods; it’s all right there, readable, and easy to test.

Common Traps to Avoid

  1. Missing dependencies – If I omitted [count] and wrote useEffect(() => { … }, []), the effect would run only once, on mount. The fetch would never refresh when the count changes, leaving the user with outdated data. The fix? List every value from the component scope that the effect uses.

  2. Stale closures – Imagine we set a timer inside the effect and forget to add the setter function to the dependency array. The callback would capture the initial setCount reference, but that’s usually fine because setters are stable. However, if we captured count itself and used it inside a setTimeout, we’d get the stale value. The rule of thumb: put all reactive values in the dependency array, or use a functional updater (setCount(c => c + 1)) when you don’t need the current value.

  3. Returning a promise directly – useEffect must return either nothing, a cleanup function, or undefined. If you accidentally return fetch(...) you’ll break React’s internal expectations. Always wrap async logic inside a regular function and call it, as shown above.

Why This New Power Matters

Hooks turned state management from a chore into a joy. Because useState and useEffect are just functions, we can extract them into custom hooks—reusable spells that encapsulate logic like data fetching, form handling, or even WebSocket subscriptions. Imagine building a useApi hook once and reusing it across dozens of components without copying‑pasting lifecycle code. That’s the kind of leverage that lets you ship features faster and keep your codebase clean.

And the best part? Hooks work side‑by‑side with class components if you ever need to migrate gradually. You can start small, convert a tricky component, and watch the complexity melt away.

Your Turn – Embark on Your Own Quest

Now that you’ve seen the power of useState and useEffect, I dare you to take a component you’ve been struggling with—maybe a form that validates on every keystroke, or a chat widget that needs to reconnect on network loss—and rewrite it using hooks. Try extracting the logic into a custom hook and share it with a teammate.

What will you build first? A live‑updating dashboard? A game‑like timer that pauses when the tab loses focus? Drop a link or a snippet in the comments—I can’t wait to see the adventures you embark on! 🚀

Top comments (0)