Part 4 of the Modern React Patterns series
In the previous articles we've been removing useEffects that existed only because React was being asked to synchronize things it already knows how to synchronize. Firstg we looked at derive state, then data fetching and then synchronizing props into state. This time the problem is slightly different. The useEffect isn't synchronizing data anymore. Instead, it's being used to react to something the user just did. Over the years I've noticed this is another pattern that slowly disappears as React developers gain experience. Not because useEffect is wrong. But because event handlers are usually a much more natural place for that logic.
A Pattern That Looks Perfectly Reasonable
Imagine a form with a Save button.
One implementation might look like this:
function UserForm() {
const [shouldSave, setShouldSave] = useState(false);
useEffect(() => {
if (!shouldSave) return;
save User();
setShouldSave(false);
}, [shouldSave]);
return(
<button onClick={() => setShouldSave(true)}>
Save
</button>
);
}
There's nothing obviously wrong here. Clicking the button changes some state. The Effect notices that change. The save operation runs. It works. I've seeen this pattern in production more times than I can count. In fact, I used to write code like this myself because it felt like I was "keeping side effects inside useEffect", which sounded like good React practice. The problem is that this Effect isn't synchronizing with an external system. It's simply reacting to an event that we already know happened.
The Event Already Happened
When someone clicks the button, React already gives us the perfect place to respond. The click handler. Instead of storing a flag that says "someone clicked Save", we can simply save immediately.
function UserForm(){
const handleSave = async() => {
await saveUser();
};
return(
<button onClick={handleSave}>
Save
</button>
);
}
The component suddenly becomes easier to read. There isn't any intermediate state or another render whose only purpose is triggering an Effect. The action happens exactly where the event occurs.
Nowadays, whenever I review code and find boolean flags like shouldSave, shouldDelete or shouldSubmit, it's usually one of the first things I look at. very often they're only there to wake up an Effect.
Boolean Flags Usually Hide Event Logic
After seeing this pattern enough times, I started noticing the same variable names appearing over and over again.
const [shouldFetch, setShouldFetch] = useState(false);
const [shouldDelete, setShouldDelete] = useState(false);
const [shouldSubmit, setShouldSubmit] = useState(false);
const [shouldExport, setShouldExport] = useState(false);
Most of these aren't really pieces of state. They're event pretending to be state. That's an important distinction. State describeshow the UI looks right now and events describe something that happened. A button click isn't application state, it's simply an interaction. When we convert events into state, we usually end up writing an Effect whose only responsibility is translating that state back into the original action.
In other words, this is the process:
User clicks --> State changes --> Component renders again --> Effect runs --> Actual work starts
That's a surprisingly long journey for something that could have happened directly inside the click handler.
Effects Are About Synchronization
One idea that completely changed the way I use useEffect came directly from the React documentation. Effects exist to synchronize your component with something outside React. That could be:
- opening a WebSocket
- subscribing to browser events
- integrating a third-party library
- controlling a video player
- updating the document title
Those are external systems, a button click, saving a form or deleting a record isn't. Those actions originate inside React and there's no synchronization involved. Once I started thinking about Effects this way, I noticed I was deleting far more of them than adding new ones.
Extra State Usually Means Extra Complexity
Let's imagine the save operation becomes slightly more complicated. We want to show a loading spinner. With the previous approach, we now have several moving parts.
const [shouldSave, setShouldSave] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if(!shouldSave) return;
async function save(){
setLoading(true);
await saveUser();
setLoading(false);
setShouldSave(false);
}
save();
}, [shouldSave]);
Nothing here is technically incorrect but notice how much machinery appeared around what was originally just "save when the user clicks". Compare it with keeping everything inside the event handler.
const [loading, setLoading] = useState(false);
const handleSave= async () => {
setLoading(true);
try{
await saveUser();
} finally {
setLoading(false);
}
};
Personally, I find this version much easier to follow. Everything related to saving lives in one place. The button starts the action, the handler performs the action and the loading state belongs to the action. There aren't multiple renders whose only purpose is coordinating when work should begin.
React Doesn't Need a Trigger Variable
One thing I notice quite often is that developers coming from other frameworks tend to think they need a variable that "activates" some behavior. React usually doesn't. If the user clicks something, React already tells us exactly where that happened. If a request should start after submitting a form, React already tells us which function handles the submit.
Adding another state variable often doesn't make the logic clearer. It simply delays the real work until after another render. That doesn't sound like much, but once components grow, these little patterns accumulate surprisingly quickly. One Effect becomes three. Then five and eventually the component spends more time coordinating itself than implementing the actual business logic.
When an Effect Is Actually the Right Choice
The point of this article isn't that every side effect should move into an event handler. That would be just another rule to follow without understanding the reason behind it. There are plenty of situations where an Effect is exactly the right tool.
The difference is what caused the work to happen. If the work started because the user clicked a button, the event handler is usually the right place. And if the work needs to happen because something outside the component changed, an Effect may be the right solution.
External Changes Are Different
Imagine a component that connects to a WebSocket.
useEffect(() => {
const socket = new WebSocket(url);
socket.onmessage = (event) => {
setMessage(event.data);
};
return () => {
socket.close();
};
}, [url]);
This is a good use of useEffect. The WebSocket is an external system. React needs to synchronize with it. When url changes, the connection needs to be recreated. When the component unmounts, the connection needs to be cleaned up. The Effect describes that lifecycle clearly. The same idea applies to browser event listeners:
useEffect(() => {
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener("resize", handleResize);
return () =>{
window.removeEventListener("resize", handleResize);
};
}, []);
The component is synchronizing with something outside React. That's what Effects are good at.
The Difference Is the Cause
Compare these two examples.
User clicks a button
<button onClick = {handleSave}>
Save
</button>
The event handler should usually perform the save.
A WebSocket sends a message
useEffect(() => {
socket.onmessage = handleMessage;
return () => {
socket.close();
};
}, [socket]);
The Effect is appropriate because the event comes from an external system. The code may look similar because both situations involve something happening. But the source of the event is different and that's the distinction I find most useful.
My Personal Rule
A useful rule I now try follow is that if something happened, handle the event. If something is now true, represent it as state.
For example:
// Event
onClick={handleSave}
is different from:
// State
const [isSaving, setIsSaving] = useState(false);
The click happened once. isSaving describes the current state of the application. Those shouldn't be trated as the same thing. When an event becomes a boolean flag just tot trigger an Effect, the code is usually becoming more complicated than necessary.
When I find myself writing something like this:
const [shouldDoSomething, setShouldDoSomething] = useState (false);
useEffect(() => {
if (shouldDoSomething){
doSomething();
}
}, [shouldDoSomething]);
I stop and ask mysellf what caused this work to happen.
If the answer is that the user clicked a button, then the code probably belongs here:
const handleClick = () => {
doSomething();
};
If the answer is a WebSocket message arrived, the url changed, a third-party library changed or the browser fired an event, then an Effect might be exactly what the component needs. This small distinction has made a big difference in the way I structure React components.
Conclusion
I don't think useEffect is difficult because the API itself is complicated. The difficult part is deciding whether the code actually belongs there.
For a long time, I treated Effects as a general-purpose place for anything that wasn't rendering UI. That usually led to state flags, dependency arrays, extra renders and components that were difficult to follow.
Now I try to start from the cause instead. If the user did something, I usually handle it in an event handler and if an external system changed, I consider an Effect. That doesn't eliminate every Effect form a React application. It just means that the ones that remain usually have a clearer reason for existing.
Next Article in This Series
Part 4: Stop Using Effects for State Resets
Previous Articles in This Series
Part 1: Why I Rarely Use useEffect Anymore (and what I use instead)
Part 2: Stop Fetching Data Inside useEffect (what modern React apps do instead)
Part 3: Stop Syncing Props Into State (you probably don't need that useEffect)
Thanks for reading!! If you're looking for help with an existing project or need someone to solve a tricky frontend/backend issue, you can also find my freelance profiles through my Dev.to profile.
Top comments (0)