I've taught React to tens of thousands of developers. Before and after hooks were released. One thing I've observed is people tend to struggle with the useEffect
hook and there are some common hang-ups for them that I'd like to address here.
❌ Lifecycles ❌ ✅ Synchronize Side Effects ✅
The biggest struggle I've observed is developers who have experience with React class components and lifecycle hooks like constructor
, componentDidMount
, componentDidUpdate
, and componentWillUnmount
. You can definitely map these to function components with hooks, but that's a big mistake. Allow me to illustrate. Here's an example of some fun UI:
Here's a class component implementation of that DogInfo
component:
class DogInfo extends React.Component {
// we'll ignore error/loading states for brevity
this.controller = new AbortController()
getDog(this.props.dogId, {signal: this.controller.signal}).then(
componentDidUpdate(prevProps) {
// handle the dogId change
if (prevProps.dogId !== this.props.dogId) {
// cancel the request on unmount
return <div>{/* render dog's info */}</div>
That's a pretty standard class component for this type of interaction. It's using the constructor
, componentDidMount
, componentDidUpdate
, and componentWillUnmount
lifecycle hooks. If we naively mapped those lifecycles to hooks, here's how it might look:
function DogInfo({dogId}) {
const controllerRef = React.useRef(null)
const [dog, setDog] = React.useState(null)
controllerRef.current?.abort()
controllerRef.current = new AbortController()
getDog(dogId, {signal: controllerRef.current.signal}).then(
// eslint-disable-next-line react-hooks/exhaustive-deps
const previousDogId = usePrevious(dogId)
if (previousDogId !== dogId) {
controllerRef.current?.abort()
return <div>{/* render dog's info */}</div>
function usePrevious(value) {
There are still some holdouts on hooks. If this is what I thought hooks meant, then I would be a hooks holdout too.
Here's the crux of the issue: useEffect
is not a lifecycle hook. It's a mechanism for synchronizing side effects with the state of your app.
So, in our example, all we care about is: "When the dogId
changes, fetch the new dog's information." With that as our goal, useEffect
becomes much simpler for this case:
function DogInfo({dogId}) {
const [dog, setDog] = React.useState(null)
const controller = new AbortController()
getDog(dogId, {signal: controller.signal}).then(
return () => controller.abort()
return <div>{/* render dog's info */}</div>
🤯 Oh snap. That's way better right?! When the React team introduced hooks, their goal wasn't to simply add lifecycles to function components. Their goal was to fundamentally improve the mental model for application side-effects. And they did.
Big time.
So remember this gem by Ryan Florence:
The question is not "when does this effect run" the question is "with which > state does this effect synchronize with" > > useEffect(fn) // all state > > useEffect(fn, []) // no state > > useEffect(fn, [these, states])
I can ignore eslint-plugin-react-hooks/exhaustive-deps ❌
Well, technically you can. And sometimes there's a good reason to. But most of the time it's a bad idea and ignoring that rule will lead to bugs. I see this concern come up often when people say: "But I only want this to run on mount!" Again. That's thinking in lifecycles and is wrong. If your useEffect
callback has dependencies, then you need to make sure that your effect callback is re-run anytime those dependencies change. Otherwise your side-effects will fall out of sync with the state of the app.
Long story short, you'll have bugs. Don't ignore this rule.
One giant useEffect ❌
Honestly, I don't see this one a whole lot, but I want to include it just in case. No shame if this is you. It happens.
One of the things I love about useEffect
over lifecycles is it allows me to really separate concerns. Here's a quick example:
Here's some pseudocode for that demo:
class ChatFeed extends React.Component {
this.subscribeToOnlineStatus()
this.subscribeToGeoLocation()
this.unsubscribeFromFeed()
this.restoreDocumentTitle()
this.unsubscribeFromOnlineStatus()
this.unsubscribeFromGeoLocation()
componentDidUpdate(prevProps, prevState) {
// ... compare props and re-subscribe etc.
return <div>{/* chat app UI */}</div>
See those four concerns? They're all mixed up. If we wanted to share that code with anything else, it would be a mess. I mean, render props were awesome, but hooks are way better.
I've seen some people create a monster useEffect
hook that does all the things:
// subscribe to online status
// subscribe to geo location
// restore document title
// unsubscribe from online status
// unsubscribe from geo location
return <div>{/* chat app UI */}</div>
But this makes that individual callback pretty complicated. I'd suggest a different approach. Don't forget that you can separate logical concerns into individual hooks:
// restore document title
// subscribe to online status
// unsubscribe from online status
// subscribe to geo location
// unsubscribe from geo location
return <div>{/* chat app UI */}</div>
And with this approach, it's much easier to extract this code into a custom hook for each individual concern if that's what you need or want to do:
// NOTE: this is pseudo-code,
// you'd likely need to pass values and assign return values
return <div>{/* chat app UI */}</div>
The self-encapsulation of hooks in general is a huge win. Let's make sure we take advantage of that.
Needlessly externally defined functions ❌
I've seen this one a few times as well. Let me just give you a before/after:
// before. Don't do this!
function DogInfo({dogId}) {
const [dog, setDog] = React.useState(null)
const controllerRef = React.useRef(null)
const fetchDog = React.useCallback((dogId) => {
controllerRef.current?.abort()
controllerRef.current = new AbortController()
return getDog(dogId, {signal: controllerRef.signal}).then(
return () => controller.current?.abort()
return <div>{/* render dog's info */}</div>
We already saw how simple the above code can be in our earlier example, but let me show that to you again:
function DogInfo({dogId}) {
const [dog, setDog] = React.useState(null)
const controller = new AbortController()
getDog(dogId, {signal: controller.signal}).then(
return () => controller.abort()
return <div>{/* render dog's info */}</div>
The specific thing I'm trying to call out here is the idea of defining a function like fetchDog
outside of the useEffect
callback. Because it's external, you have to list it in the dependencies array to avoid stale closures. And because of that you also have to memoize it to avoid infinite loops. Oh, and then we had to create a ref
for our abort controller.
Phew, seems like a lot of work. If you must define a function for your effect to call, then do it inside the effect callback, not outside.
Conclusion
When Dan Abramov introduced hooks like useEffect
, he compared React components to atoms and hooks to electrons. They're a pretty low-level primitive, and that's what makes them so powerful. The beauty of this primitive is that nicer abstractions can be built on top of these hooks which is frankly something we struggled with before hooks. Since the release of hooks, we've seen an explosion of innovation and progress of good ideas and libraries built on top of this primitive which ultimately helps us develop better apps.
I love it. And I want to teach you all about these primitives as well as abstractions built on top of them in EpicReact.Dev 🚀 Join me.