loading...

Effects are not lifecycles

samsch_org profile image Samuel Scheiderich Originally published at samsch.org ・5 min read

You can't write lifecycles with useEffect.

With React hooks being widely regarded as "better" than using classes in the React community, both for new users and for experienced developers, there's a wide pattern of developer migration to learn the new tools.

Most of these developers are bringing with them the concepts they've gotten used to with React classes and even from non-React frameworks or tools. Some of these are easy to directly transfer across: It's not terribly hard to pick up useState if you are used to class state, and useRef is fairly straight forward for many as well, once they get the basic concept of how hooks hold on to state.

(Originally published here)

Lifecycles are "when" you do things

React class component authors are used to writing functionality in lifecycles, and lifecycles don't exist with hooks. You can emulate them if you're careful, maybe using some useRef instances to reference changing props because of closures. But emulating lifecycles is a bad idea, and the reason why is this: Effects are a higher-level abstraction than lifecycles.

When you use a lifecycle like componentDidMount, or componentDidUpdate (let alone the older deprecated lifecycles which ran at different stages), you must think in terms of when something should happen. "I want the data loaded when the component mounts." "I want to load data if when the component updates with a new X prop." This idea of "when" is procedural thinking. The "when" concept isn't actually important, but because the tool for completing these tasks is lifecycles, you need to map the "what" that you want to do, to the "when" of a specific lifecycle.

Well I'm here to tell you to forget all of that. Seriously, forget the concept of "when" entirely. You don't care when something happens. You really don't. You think you might for this specific thing? You don't.

Effects are "what", not "when"

React is a strict model. It's part of why it's so powerful and flexible. The model says "given X state, the view should be viewFunction(X)". For a long time, we had to break this model for anything that wasn't direct view output. Instead of "given X state, do effectFunction(X)", we had to break down when we wanted those things to happen, and sort them into lifecycle methods.

With useEffect, you say "given X state, do effectFunction(x)". What's important now is just what your state is, and what you should do given that state. "When" doesn't matter anymore. With lifecycles, you would do async loads of your data in componentDidMount. You did it at mount, because you know it's not previously been done then. But do you actually care about it being at mount? Isn't what really matters that you load the data if it hasn't already been loaded? So we just boiled it down to the important part: If our data is not yet loaded, then load the data.

That concept is how useEffect works. We don't care that the component is mounting, we just write in our useEffect that we want the data to be loaded if it hasn't been already. What's more, from a high level, we don't usually even care if it loads the data multiple times, just that the data gets loaded.

What it looks like in code

Now we've boiled down the what that we want to do. "When data isn't loaded, load the data."

The naive approach looks like this:

const [isLoaded, setLoaded] = useState(false);
const [data, setData] = useState(null);

useEffect(() => {
  if (isLoaded === false) {
    loadData().then(data => {
      setData(data);
      setLoaded(true);
    });
  }
});

This code works. It's the most naive approach given our concept of what we want, but it works perfectly fine.

Arguably, there are more naive approaches, but we're making the assuming here that we already know how hooks work, so we aren't taking into consideration putting the useEffect() inside the condition, since that is a known error.

Let's compare that to what the code looks like if you emulate componentDidMount using [] as a second argument.

const [data, setData] = useState(null);

useEffect(() => {
  loadData().then(data => {
    setData(data);
    setLoaded(true);
  });
}, []);

At first glance, there is less code involved, which you might argue is a good thing. But this code doesn't describe the situation as well. We have implicit state. It looks like loadData() should run every time, because there is no semantic code which says it won't. In other words, we aren't describing what the code is actually supposed to do. If you remove the [], then this code looks almost identical, but simply doesn't work properly (it always loads data, instead of only if we need it). What's more, we very likely need the loading state in render anyway, and while you can assume that null data means it's not loaded, you are breaking single responsibility principle by overloading the meaning of a variable.

This is a very common stumbling block that people trip over when learning hooks, because they try to emulate lifecycles.

Optimizing

Now, for practical purposes, we don't actually want the loadData function called more than once. If you follow the simplest application of what belongs in the useEffect dependencies argument (every outside reference), this is automatically fixed:

const [isLoaded, setLoaded] = useState(false);
const [data, setData] = useState(null);

useEffect(() => {
  if (isLoaded === false) {
    loadData().then(data => {
      setData(data);
      setLoaded(true);
    });
  }
}, [isLoaded, loadData, setData, setLoaded]);

The two setters won't change, but they are semantically deps of the function, and maybe down the road they get replaced by something that might change. We'll assume for now that loadData won't change (if it did, it will only trigger a new call if isLoaded is still false). Our key dependency here is isLoaded. In the first pass, React automatically runs the effect, and isLoaded is false, so loadData() is called. If the component renders again while isLoaded is still false, the deps won't have changed, so the effect won't run again.

Once loadData() resolves, isLoaded is set true. The effect runs again, but this time the condition is false, so loadData() isn't called.

What's important to take away from this is that the dependency argument didn't change our functionality at all, it just reduced unnecessary calls to a function.

But what about things that shouldn't be loaded more than once!

Ah, right. Maybe it's making a call which changes something somewhere else. It should only be called once when needed.

This means our "what" changed. It's no longer "if not loaded, load data", it's now: "if not loaded, and not already loading, load data." Because our "what" changed, our semantic code should change too.

We could simply add an isLoading state, but then we could have something confusing happen like isLoading and isLoaded both true! Since these state should be exclusive, that means they are also related. And more than related, they are actually the same state field (the data status), just different values.

So now we change our state code to reflect our new "what":

const [dataStatus, setDataStatus] = useState('empty');
const [data, setData] = useState(null);

useEffect(() => {
  if (dataStatus === 'empty') {
    loadData().then(data => {
      setData(data);
      setDataStatus('available');
    });
    setDataStatus('loading');
  }
});

Now we have code which only calls loadData() when we need it and it isn't already loading, AND it doesn't use the dependency argument of useEffect.

Additionally, the different parts of our state are all explicitly included here.

Tell me what to do!

So, forget about lifecycles, mounting, updates, and generally "when" thing happen. Just completely put it out of your head.

Think about what you need to do, and what the states are that should cause those things to happen.

Model those states explicitly in your code, and model the effects run based on those states.

Your code should always work without using the second argument to useEffect. If you need, the second argument, you are probably incorrectly coding your functionality.

Discussion

markdown guide
 

Your code should always work without using the second argument to useEffect. If you need, the second argument, you are probably incorrectly coding your functionality.

I would argue the second argument is required almost every time for optimizations. If you're not using the second argument, then its called for each render. You might as well not even use useEffect then.

I do agree though that a mindset shift is required to work with hooks instead of lifecycle methods.

 

Hey Abdisalan,

The value of useEffect is in the guarantees it makes around being a safe place to do side effects. Render can be called in cases where the component didn't render, and generally React is built around an assumption that you won't do side effects there.

Consider that you don't even have the option to optimize this way with lifecycles either. If you want to do something conditionally based on state after some renders with a class component, you do it in componentDidUpdate by testing those state values. But componentDidUpdate always runs.

Part of the mindset shift is to not think about these "when" conditions for your semantic code, and to instead model the state explicitly. If something shouldn't run for every render, then it should run conditionally based on some state. The deps array shouldn't be needed for that.

 

You're right, in some cases you probably couldn't get away with putting side effects outside of the useEffect. However, using the second argument is still a powerful tool that should be used and doesn't mean you are "incorrectly coding functionality"

 

I just have one thing against useEffect and is my own opinion right now. For me, functions should do only one thing and do it right. If you write a function that sums, it shouldn't worry about which conditions are set in their input parameters.

This weird, innecesary race conditions that the second parameter put into the game just make me miss react lifecycle more.