The Quest Begins (The "Why")
I still remember the first time I tried to fetch data inside a React component and ended up with an infinite loop that made my browser scream. I was building a simple dashboard that needed to show a list of users whenever a filter changed. I slapped a fetch call straight into the render function, thinking “hey, it’ll just run when the props change”. Spoiler: it ran on every render, triggered a state update, which caused another render, and boom — my tab froze like a wampa in a snowstorm.
That moment was my “why”. I realized that React’s render cycle is a delicate dance, and if you don’t know when to step, you’ll keep stepping on your own toes. I needed a way to say “run this piece of code only when X changes” and “keep this piece of data alive across renders without causing a rerun every time”. Enter useState and useEffect — the two lightsabers every React Jedi must wield.
The Revelation (The Insight)
Here’s the magic in a nutshell:
-
useStategives you a mutable slice of state that survives re‑renders. It returns[state, setState]. When you call the setter, React schedules a render with the new value. -
useEffectlets you run side‑effects (data fetching, subscriptions, manual DOM changes) after React has flushed changes to the DOM. You tell it when to run by providing a dependency array.
The big “aha!” for me was understanding that useEffect isn’t just a replacement for componentDidMount/componentDidUpdate. It’s a unified API that lets you declaratively sync your component with the outside world. If you pass an empty array [], the effect runs once after the initial render — perfect for a initial data load. If you pass [searchTerm], it runs whenever that term changes — ideal for a search‑box debounce.
And the trap? Forgetting the dependency array altogether. An effect with no second argument runs after every render, which is usually not what you want and can lead to those nasty loops we all dread.
Wielding the Power (Code & Examples)
Let’s see the struggle first, then the victory.
The Struggle: Manual state & effects in class components
class UserList extends React.Component {
state = { users: [], loading: false };
componentDidMount() {
this.fetchUsers();
}
componentDidUpdate(prevProps) {
if (prevProps.filter !== this.props.filter) {
this.fetchUsers();
}
}
fetchUsers = () => {
this.setState({ loading: true });
fetch(`/api/users?filter=${this.props.filter}`)
.then(res => res.json())
.then(data => this.setState({ users: data, loading: false }))
.catch(err => console.error(err));
};
render() {
if (this.loading) return <p>Loading…</p>;
return (
<ul>
{this.users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
}
Look at all those lifecycle methods! Keeping componentDidMount and componentDidUpdate in sync is error‑prone, and if I ever added another prop that should trigger a refetch, I’d have to remember to update the didUpdate check.
The Victory: Hooks version
import React, { useState, useEffect } from 'react';
function UserList({ filter }) {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
// Runs once on mount AND whenever `filter` changes
useEffect(() => {
setLoading(true);
fetch(`/api/users?filter=${filter}`)
.then(res => res.json())
.then(data => {
setUsers(data);
setLoading(false);
})
.catch(err => {
console.error(err);
setLoading(false);
});
}, [filter]); // <-- Dependency array tells React when to re‑run
if (loading) return <p>Loading…</p>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
What changed?
-
State is now declared inline with
useState. No morethis.stateceremony. -
Side‑effects live in one place (
useEffect). The dependency array[filter]is the declarative contract: “run this effect wheneverfilterchanges”. - Cleanup is easy – if we ever needed to abort a fetch or unsubscribe, we could return a function from the effect.
Common Traps (The “Bosses” to Avoid)
-
Missing dependencies – If I omitted
[filter], the effect would run after every render, causing infinite loops when I set state inside it. -
Stale closures – Imagine I used a variable from the outer scope inside the effect without listing it as a dependency. The effect would capture the initial value forever. The fix? Either add it to the dependency array or use a functional updater (
setUsers(prev => …)). -
Returning a promise directly –
useEffectexpects either nothing or a cleanup function. If you forget to wrap async logic, you’ll get a warning. The pattern above (define an async function inside and call it) is the cleanest way.
Why This New Power Matters
With useState and useEffect in your toolkit, you stop fighting React’s render cycle and start partnering with it. You can:
- Fetch data exactly when you need it, without manual lifecycle juggling.
- Sync with external systems (WebSockets, intervals, subscriptions) with a clear start/stop contract.
-
Derive state from props or other state variables without creating messy
getDerivedStateFromPropshacks. - Write components that are easier to test because side‑effects are isolated and declarative.
In short, your components become more predictable, your code shorter, and your debugging sessions far less terrifying. It feels like finally mastering the Force after years of swinging a lightsaber blindly.
Your Turn – The Challenge
Pick a component you’ve built with class components or with scattered useEffect calls that run too often. Refactor it using useState and a single, well‑thought‑out useEffect. Pay special attention to the dependency array — make it as tight as possible.
Once you’ve done it, drop a link to your gist or a CodeSandbox in the comments. I’m curious to see what you’ll build, and I’ll be cheering you on like a fellow Jedi watching a Padawan nail their first lightsaber form.
May your state be steady and your effects be pure! 🚀
Top comments (0)