loading...
Cover image for The Botched Implementation of useEffect()

The Botched Implementation of useEffect()

bytebodger profile image Adam Nathaniel Davis ・10 min read

If you're a React dev, and you love those tasty Hooks, we need to talk. And just like when your significant other texts you, "we need 2 talk", this little discussion will probably be uncomfortable. You may even become defensive. But we really need to have a heart-to-heart about useEffect().


Alt Text

Fanboys Be Gone

Before I dive into the Epic Sadness that is useEffect(), I'm gonna throw out a few critical disclaimers for all the Keyboard Kommanders out there who are cracking their knuckles and preparing to give me a righteous beat-down.

First, I use Hooks. All the time. In fact, at this point, I only use Hooks and function-based components. So please don't waste your time trying to defend Hooks. Do you love them? Great! So do I! This post has nothing to do with being a Hooks Haterrr.

Do you hate classes?? Well, I don't entirely share that hatred, but nothing in this post is in "defense" of classes. So again... don't waste your time trying to defend useEffect() by pointing out that "classes are da sux".

I'm not trying to talk about React vs. Svelte vs. Vue. Or functional programming vs. OOP. Or tabs vs. spaces. Or chocolate vs. strawberry. I'm not (in this article) trying to dive into any broader Holy Wars. In this article, I'm simply examining the shortcomings of one particular language feature: useEffect().

So with those disclaimers out of the way, let's talk about how useEffect() came about...


Alt Text

In The Beginning

In the beginning, there were class-based components. And they were good. (OK, OK. I get it. A lotta React devs have some kinda deep-seated hatred for anything that uses the class keyword. So maybe you don't think that class-based components were good at all. But they undeniably worked. If they didn't, React would've died on the vine, years ago.)

And in the beginning, class-based components had lifecycle methods. And they were good. (OK, OK. I get it. Lifecycle methods could, at times, be a complete PITA. Every three days, another lifecycle method would get renamed to something like: THIS_IS_UNSAFE_AND_YOU_SHOULD_NEVER_USE_IT(). And lifecycle methods are fabulous at creating unintended, endless re-renders. But there were many critical tasks in the development of large-scale React apps that simply could not be done without lifecycle methods.)

On the seventh day, the React team rested. But they started worrying about the continual stream of fanboys who were wringing their hands over that ugly, nasty class keyword. And they decided to correct this "design flaw" with a great flood known as Hooks.

Hooks wiped away all the nastiness that came from those unconscionable class-based components. Now the FP fanboys could rejoice with all of their pure functions. And they could rejoice at never having to use the class keyword again. But along the way, a critical piece of functionality was left off the ark. In the rush to adopt all-functions-all-the-time, React's lifecycle methods were treated as... an afterthought.


Alt Text

Naming Things Is Hard

The first clue that something might be amiss with useEffect() actually comes from its name. Any time you have a core feature with an impossibly-vague name, it's a sign that the feature might be trying to do too much.

Several years ago, I worked with a guy who was fond of naming methods doWork(). I don't mean that he created methods that were something similar to doWork(). I mean that I'd look at the classes he'd created (we were writing in Java) and, quite frequently, the class would contain a method that was named, literally, doWork(). In fact, it was not uncommon to find that the majority of the class's logic was contained inside doWork().

What exactly did doWork() do??? Well, obviously, it did... work. Ohhhh... you want to know what type of work it did??? Well, that could only be understood if you spent copious hours reading through each of the doWork() methods on your own. Because, unlike methods such as calculateSalesTax() or saveShoppingCart(), there's no way to see a call to a doWork() method and have any clue about what exactly is expected to happen.

So what happens when useEffect() is invoked??? Well, umm... it creates... an effect. What kind of effect? Yeah, well... you'll just have to read through the code on your own to figure that out. Because the function name itself provides no such information. useEffect() is barely more descriptive than compute() or useLogic().


Alt Text

Lost Time

Maybe this sounds like an unfair critique, because the lifecycle methods that came with class-based components also didn't tell you what happens inside them. What happens inside componentDidMount()??? It's impossible to know, based on nothing more than the method name. What happens inside componentWillUnmount()??? It's impossible to know, based on nothing more than the method name.

But the names of lifecycle methods don't tell us what happens. They tell us when it happens. I don't know exactly what logic you put inside that componentDidMount() method, but I can tell you, with absolute certainty, that it will happen immediately after the component is mounted. And I don't know exactly what logic you put inside that componentWillUnmount() method, but I can tell you, with absolute certainty, that it will happen immediately before the component is unmounted.

When does the logic inside useEffect() get invoked?? Well, ummm... that depends. It depends upon how you call it. I won't rehash that all in this article. Instead, I'll just refer to another blogger's excellent article right here on Dev.to: https://dev.to/spukas/4-ways-to-useeffect-pf6

As you can see in the linked article, useEffect() could happen on mount, or after all renders, or after some renders. And if you include a return statement, it could also trigger logic on unmount.

I've been programming for a quarter century and I've been doing React for more than five years. But I have the article above bookmarked because I find myself routinely referring back to it. I have to constantly remind myself how the exact same language construct can be used to invoke four very-different behaviors.

This array of behaviors happens because you have a single Hook designed to replace the methods which previously governed multiple stages in a component's lifecycle. On a practical level, this means that you can't immediately tell when useEffect() will be called - until you manually assess the way in which it's being called.

Ughh...


Alt Text

Doing Too Many Things

As long as I've been programming, there's been a basic aphorism that has helped me, time and time again, to write better code:


A function should do one thing, and only one thing - and do it well.



useEffect() violates this basic tenet. As already discussed, I fully understand that its name will never tell me what it's doing. But I can't even tell (from the name) when it's doing.

It can't possibly tell me when it's doing, because it's trying to replace all of the lifecycle methods that existed in class-based components. It's trying to replace them all - with a single function. It's like the Hooks team hired my former colleague who constantly wanted to write doWork() methods. But now, he just writes useEffect().



Alt Text

Missing Pieces

There are many reasons to discourage writing functions that doALLTheThings(). One of the strongest reasons is that, any time you try to doALLTheThings(), invariably, you end up leaving something out.

Sometimes it's an error of omission. Other times, it's an error of outright arrogance. In other words, when you try to doALLTheThings(), it's inevitable that, eventually, someone notices a key feature that's been left out of doALLTheThings(). And just as inevitably, there's a strong tendency for the function's programmer to reply that, "You don't actually need that feature."

This is what's happened with useEffect(). First, we get this kind of arrogance from the Hooks Crowd:

Class Crowd: I need to use componentWillMount().
Hooks Crowd: Just use useEffect()!!!
Class Crowd: I need to use componentDidMount().
Hooks Crowd: Just use useEffect()!!!
Class Crowd: I need to use componentWillReceiveProps().
Hooks Crowd: Just use useEffect()!!!
Class Crowd: I need to use shouldComponentUpdate().
Hooks Crowd: Just use useEffect()!!!
Class Crowd: I need to use componentWillUpdate().
Hooks Crowd: Just use useEffect()!!!
Class Crowd: I need to use componentDidUpdate().
Hooks Crowd: Just use useEffect()!!!
Class Crowd: I need to use componentWillUnmount().
Hooks Crowd: Just use useEffect()!!!
Class Crowd: I need to use getDerivedStateFromProps().
Hooks Crowd: Just use useEffect()!!!
Class Crowd: I need to use getSnapshotBeforeUpdate().
Hooks Crowd: Just use useEffect()!!!
Class Crowd: I need to use componentDidCatch().
Hooks Crowd: Just use useEffect()!!!

Second, if they can't explain exactly how useEffect() replaces a lifecycle method, they just dismiss the feature altogether by stating that we don't actually need it. I saw this not-too-long ago when I wrote an article about the lack of constructors in functional components. (You can read it here: https://dev.to/bytebodger/constructors-in-functional-components-with-hooks-280m)

[FWIW, I understand that a constructor isn't often defined as a "React lifecycle method". But with class-based components, it absolutely is a quantifiable stage in the component's lifecycle.]

The Hooks documentation states that, because you can initialize a state variable inside the useState() call, there's no need for a constructor. Of course, this relies on the (extremely shortsighted) idea that initializing state variables is the only logical use for a constructor.

Furthermore, there were commenters on my previous article who seemed baffled as to how a constructor would even be used in a functional component. The clear inference was: "I don't personally have a use for a constructor-like feature in a functional component, so you shouldn't need it."

But something funny happened after I wrote that article. It's gained a steady stream of new readers, week-by-week and month-by-month.

That may not sound intriguing to you, but on Dev.to, most articles get nearly all of their views within the first week-or-so after they're published. In fact, I've written many articles that can now go months without registering a single new view. But my constructor article keeps getting more views - so many, in fact, that it's now my most-read article.

What does that tell us?? Well, I interpret it to mean that there are a lotta people out there googling how to do a constructor in a React functional component. Since there aren't many other articles written about it (and since the official Hooks docs basically tell you to get over it), they end up finding my article on the subject.

In other words, I'm not the only person out there who feels that useEffect() doesn't magically replace every single lifecycle method that we had at our disposal in class-based components.


Alt Text

(Over) Simplification

In general, simplification in programming is a good thing. "Complex" code is usually synonymous with "bad" code. Complexity breeds bugs. Complexity increases costs. And time (which is... a cost).

But simplification can go too far. When simplification obfuscates what's actually happening, the simplification itself can be an obstacle. If simplification keeps us from implementing critical features, it can actually increase our costs. And our time (which is... a cost).

In many ways, useEffect() oversimplifies (obfuscates) the React lifecycle that is always there - whether you're writing class-based or functional components. It's entirely possible that all of those old lifecycle methods aren't necessary. Maybe, some of them cause more problems than they solve. But those lifecycle methods represent a fine-toothed tool by which we can peer into - and "adjust" - much of what's happening "under the covers" during that magical React update cycle.

You may strive to avoid using lifecycle methods. You may write thousands of LoC without ever reaching for one of them. But man, when you really need one of them, they can be a lifesaver. And even if you never actually use any of those lifecycle methods, merely knowing about them and understanding their (potential) use provides greater insight into the inner-workings of React.

But trying to throw it all behind a single, utilitarian, Swiss army knife function like useEffect() is like having a software company tell you, "Don't you worry about any of that. We'll just make it all... work. You don't need to do anything at all. We'll just doWork() for you."

I've already noticed this effect when I'm talking to some React devs who gulp from the functional programming fountain. They write their functional components, and they sprinkle them all with Hooks, and then they talk as though there is no real React lifecycle to be concerned with. And to be clear, I kinda understand why they think this way.

In vanilla JS, a function has no lifecycle. You call it - or you don't. It runs - whenever you call it. And it doesn't run if you don't call it. It's that simple. But in React, functional components are not so simple.

React's functional components typically return some kind of JSX. And when they do, that rendering cycle is handled somewhat automagically under the covers by React's virtual DOM. In fact, I wrote a separate article about the reconciliation process here: https://dev.to/bytebodger/react-s-render-doesn-t-render-1jc5 (And yes, I know that it's possible to trace every single re-render - but it's also a complete PITA that can sometimes be difficult to track and understand.)

The reconciliation process doesn't go away if you switch from class-based components to functional components. It's still there. Under the covers. Working the same as it ever has.

The difference is that, in class-based components, there's this rich library of lifecycle methods that allows us, if necessary, to get our fingers into the gears a bit. And even when it's not necessary, the lifecycle methods serve as a kind of living documentation that highlights all the steps a component goes through during the entire rendering/updating/reconciliation cycle.

But with Hooks, we just have one, vague, amorphous "lifecycle" method called useEffect(). It's a helluva lot like opening all your classes and seeing nothing but doWork() methods.

Discussion

pic
Editor guide
Collapse
josead profile image
José Antonio Domínguez

I'm pro simplification, because it gives you more room for creativity and to insert new ideas, plus going to directions where maybe lifecycles of components will not matter anymore. I agree on the naming part, but thats what a library is supposed to do, abstract the how it works in order to use it with ease, again, I agree that I found myself searching how to do componentWill/Did/Thing in hooks, but what I'm guessing is that we are evolving from component life cycles development to something more abstract. It happens that we are in the middle of that transition.
:)
Oh and I found this!
github.com/futurist/react-life-hooks
This is barely new, you have smart people doing this for you already.

Collapse
bytebodger profile image
Adam Nathaniel Davis Author

First, thanks for that link! That package looks kinda awesome.

Second, I'm generally pro-simplification. I think any decent programmer is. But there's a difference between "simplification" versus "we simply removed a whole bunch of the previous functionality". If you take my car, then you hand me back nothing but a steering wheel, I'm not calling that "simplification".

I do understand that any time you move from Paradigm A to Paradigm B, the first reaction is to look for all of the exact tools/features/methods that you used under Paradigm A - and to complain when they're not there. I also understand that, often, Paradigm B isn't just a new feature, but is instead a completely different way of thinking about the problem.

...but thats what a library is supposed to do, abstract the how it works in order to use it with ease, again...

That's basically the crux of my argument. I've been using Hooks quite heavily now for about 6 months. And I've found little about useEffect() that I'd call "ease". It's confusing to remember when useEffect() will be invoked, based on the way in which it's called. It's also woefully incomplete.

It's like the Hooks team took my car, gave me back nothing but a steering wheel, patted themselves on the back, and left me wondering how in the heck I'm supposed to get to the grocery store. Telling me that they abstracted away my car doesn't do me any good. I'm still standing in my garage with nothing but a steering wheel. Telling me that I can just take the bus is not a viable answer. Assuring me that I really don't need a car is not a viable answer.

Collapse
sirseanofloxley profile image
Sean Allin Newell

That's neat! I like the fact that declaring custom, reusable hooks is so easy. Maybe useEffect isn't descriptive enough, but onInit sure is.

Collapse
dorgan profile image
dorgan

I don't do react but I'm familiar with hooks-like solutions
What I see here is friction between your imperative way of thinking and the declarative style of hooks.
You have problems figuring when exactly, in lifecycle terms, an effect will be run, in which way and so on and so forth
With hooks, and declarative expressions in general, you don't care. You just say "run this effect whenever any of these dependencies change" and let the runtime figure out the rest. Returning a cleanup function is a very common pattern not only un react

It's not that useEffect is doing too much, it is really simple and limited in scope(think of compostable streams like flyd or observables), the rest is up to the runtime. You only care about what the component is, stuff like lifecycles callbacks fit un a different paradigm

Last but not least, you need to properly understand the paradigm, not try to shoehorn ideas from another paradigm because ir not always matches 1:1 and both require different mindsets

Check james-forbes.com/?/posts/alternati... to see it from a different point of view, it doesnt have that much to do with "functions don't have lifecycles" but rather that ir helps write more declarative code

Not trying to say your concerns are not valid, they are, but that it's a matter of perspective and a different focus on what to abstract. Functional programming is a deep rabbit hole and, to be honest, I don't think many of people that claim to write functional js actually understand what it's all about. It requires a lot of dedication beyond "yeah pure functions and mapa/reduce"

PS sorry if there are lots of orthographic errors, My phone doesnt like it when I write in english

Collapse
bytebodger profile image
Adam Nathaniel Davis Author

Thank you for the feedback! You're actually hitting upon topics that I've addressed in some of my past articles - particularly with regard to declarative style. I won't try to repeat those articles in this comment, but here is a quick breakdown:

  1. You're absolutely correct that declarative style is, in many respects, a complete paradigm shift.
  2. I actually love declarative programming, and it's one of the things that draws me to React.
  3. Even though I love declarative programming, I earnestly believe that some React devs try to follow it to a fault.
  4. As you've pointed out, declarative programming doesn't care about the when. However, I think it's honestly kinda silly to assume that we can build most large-scale applications without ever worrying about the when. When can be extremely important - and sometimes, there simply is no declarative analog when you need to ensure that A is followed by B which is followed by C.
  5. A perfect example of this is with API calls. I've seen soooo many React API tutorials that try to cram all the API calls into a declarative paradigm - and they haphazardly leak repeated, unnecessary HTTP calls.
  6. So while I agree with the theory of what you're saying, I don't think it's accurate (or wise) to assume that all issues (with useEffect() - or with any other aspect of the framework) can simply be "washed away" by switching paradigms to a declarative model.
  7. Thanks for that link. I don't have the time to read through all of it at this minute, but I'm definitely bookmarking it.
Collapse
dorgan profile image
dorgan

Will try to read more on your series, I've only read the hooks vs classes and outdated comparisons ones, so apologies if I missed something youve already addressed. I don't think we disagree in general.
I want to comments, though:

However, I think it's honestly kinda silly to assume that we can build most large-scale applications without ever worrying about the when. When can be extremely important - and sometimes, there simply is no declarative analog when you need to ensure that A is followed by B which is followed by C.
A perfect example of this is with API calls. I've seen soooo many React API tutorials that try to cram all the API calls into a declarative paradigm - and they haphazardly leak repeated, unnecessary HTTP calls.

When is clearly important and you should consider it, but not in the "when this happens do this" but rather "according to these relations, this is what everything looks like", not in the "in this t, x is 5" but "x is the result of this relations, for any t". Bartosz Milewski calls it the local vs global approach.

This can be achieved and, if going down the road of function compositions, thats where the idea of monad comes into play. It's doable and not really that complex, but, well, explaining monads is kind of a meme, sadly.
I've been studying some category theory, ocaml and Haskell lately to understand this very kind of situations. It's a long journey but it does clear a lot of doubts.

So while I agree with the theory of what you're saying, I don't think it's accurate (or wise) to assume that all issues (with useEffect() - or with any other aspect of the framework) can simply be "washed away" by switching paradigms to a declarative model.

It's clearly not, it all comes down to the quality of the abstractions, imho, and that doesnt have much to do with imperative vs declarative. Hooks are not inherently better than classes, and vice versa and I think you're right to point that out.

Even though I love declarative programming, I earnestly believe that some React devs try to follow it to a fault.

This is spot on.

Thread Thread
bytebodger profile image
Collapse
stereoplegic profile image
Mike Bybee

I'd like to take this opportunity to announce three new React custom hook libraries: useLogic, useCompute, and useDoWork.

Clearly, what they do is self-explanatory. As such, the code is not commented and there are no READMEs.

Collapse
bytebodger profile image
Collapse
peerreynders profile image
peerreynders

Seems to me useCallback could have benefitted from some additional Hammock time as well.

// useCallback avoids the changing props value
// issue of newly created functions that don't behave
// differently - but is doesn't address creating garbage
// (i.e. unnecessary) functions on the majority of calls
//
const stepDelta = useCallback(() => setDelta(incDelta), []);
const stepCount = useCallback(() => setCount(count => count + delta), [delta]);

In terms of design I would have expected something more along these lines:

// custom hook
function useRefreshCallback(refreshCallback, isEqual, deps) {
  const [state, setCallback] = useState(null);

  if (state !== null && isEqual(state.lastDeps, deps)) {
    return state.callback;
  }

  const fresh = refreshCallback(deps);
  setCallback({callback: fresh, lastDeps: deps});
  return fresh;
}

Example usage:

// Component Parts
const initialDelta = 1;
const incDelta = delta => delta + 1;
const refreshDeltaCallback = ([setDelta]) => {
  console.log('refresh delta callback');
  return () => setDelta(incDelta);
};

const initialCount = 0;
const refreshCountCallback = ([delta, setCount]) => {
  console.log('refresh count callback with delta:', delta);
  const incCount = count => count + delta;
  return () => setCount(incCount);
};

// ...

// Component
// contrived use of useRefreshCallback - "cost of checks" yada, yada
function App() {
  const [delta, setDelta] = useState(initialDelta);
  const [count, setCount] = useState(initialCount);

  // call `refreshCallback` function only if dependencies change
  const stepDelta = useRefreshCallback(refreshDeltaCallback, depsEqual, [setDelta]);
  const stepCount = useRefreshCallback(refreshCountCallback, depsEqual, [delta, setCount]);

Gist: useRefreshCallback - custom hook that also avoids creating garbage functions.