A visual guide to React Mental models, part 2: useState, useEffect and lifecycles

I love mental models. They’re crucial to understanding complex systems, allowing us to intuitively grasp and solve complex problems.

This is the second of a three-part series of articles around React mental models. I’ll show you the exact mental models I use with complex React components by building them from the ground up and by using lots of visual explanations.

I recommend you read part 1 first, as the mental models in this article are relying of the ones I explained there. If you want a refresher, here’s the complete mental model for part 1

Whether you’ve been working with React for years or are just starting, having a useful mental model is, in my opinion, the fastest way to feel confident working with it.

You’ll learn:

  • The useState hook: how it magically works and how to intuitively understand it.
  • The component’s lifecycle: Mounting, Rendering, Unmounting: the source of many bugs is a lack of a good mental model around these.
  • The useEffect hook: how this powerful Hook actually works?

Let’s start!

What are mental models and why are they important?

A mental model is a thought process or mental image that helps us understand complex systems and to solve hard problems intuitively by guiding us in the right direction. You use mental models everyday; think of how you imagine the internet, cars, or the immune system to work. You have a mental model for every complex system you interact with.

The mental model for React so far

Here’s a very quick overview of the React mental model I explained in part 1, or you can find the complete version for part 1 here.

A React component is just like a function, it receives props which are a function’s arguments, and it will re-execute whenever those props change. I imagine a component as a box that lives within another box.

Each box can have many children but only one parent, and apart from receiving props from its parent, it has an internal, special variable called state, which also makes it re-execute (re-render) when it changes.

Mental model of a React component re-rendering when props or state change, cleaner than in part 1

When props or state changes the component re-renders

The useState hook: state in a bottle

I showed how state works in part 1, and how it is is a special property inside a box. Unlike variables or functions which are re-declared on every render, the values that come out of useState always consistent across renders. They get initialized on mount with a default value, and can only be changed by a set state event.

But how can React prevent state from losing its value on each render? The answer is scope.

I explained the mental model for closures and scope in pat 1. In short, a closure is like a semi-permeable box, letting information from the outside get in but never leaking anything out.

A mental model for JavaScript closures, showing different React app and scripts as boxes within Global / Window

Javascript closures visualized

With useState, React scopes its value to the outermost closure, which is the React app containing all your components. In other words, whenever you use useState React returns a value that is stored outside your component and hence not changing on each render.

React manages to do this by keeping track of each component and the order in which each hook is declared. That’s the reason you can’t have a React Hook inside a conditional. If useState, useEffect, or any other hook is created conditionally then React cannot properly keep track of it.

This is best explained visually:

React state scope mental model showing a box with state living in the outer box and not the component box

React state is scoped to the outer-most box, that way it doesn't change on every render

Whenever a component is re-rendered useState asks to get the state for the current component, React then checks a list containing all states for each component and returns the corresponding one. This list is stored outside the component because on each re-render variables and functions are created and destroyed.

Although this is a technical view of how state works, by understanding it I can transform some of React’s magic into something I can visualize. For my mental model I simplify things into a simpler idea.

My mental model when working with useState is this: since state is not affected by what happens to the box, I imagine it as a constant value within it. I know that no matter what happens state will remain consistent throughout the lifetime of my component.

Mental model of a React component with state, with state as a constant at the top of the box

State remains constant even though the component could change

How does state change?

Once we understand how state is preserved, it’s important to understand how it changes.

You may know that state updates are async, but what does that mean? How does it affect our everyday work?

A simplified explanation of sync and async is:

  • Syncronous code blocks the JavaScript thread, where your apps runs, from doing any other work. Only one piece of code can be run at a time in the thread.
  • Asyncronous code doesn’t block the thread because it gets moved to a queue and runs whenever there’s time available.

We use state as a variable, but updating it is async. This makes it easy to fall into the trap of thinking that a set state will change its value right away like a variable would, which leads to bugs and frustration, for example:

const Component = () => { const [searchValue, setSearchValue] = useState(''); const handleInput = e => { setSearchValue(e.target.value); fetchSearch(searchValue).then(results => { }); };
};

This code is buggy. Imagine a person types Bye. The code will search for by instead of bye because each new stroke triggers a new setSearchValue and fetchSearch, but because state updates are async we’re going to fetch with an outdated searchValue. If a person types fast enough and we have other JavaScript running, we may even search for b since JavaScript didn’t have time to run the code from the queue yet.

Long story short, don’t expect state to be updated right away. This fixes the bug:

const Component = () => { const [searchValue, setSearchValue] = useState(''); const handleInput = e => { const search = e.target.value; setSearchValue(search); fetchSearch(search).then(results => { }); };
};

One of the reasons state updates are async is to optimize them. If an app has hundreds of different states wanting to update at once React will try to batch as many of them as possible into a single async operation, instead of running many sync ones. Async operations, in general, are more performant too.

Another reason is consistency. If a state is updated many times in quick succession, React will only take the latest value for consistency’s sake. This would be difficult to do if the updates were sync and executed right away.

An image of code setting state in a React component next to a mental model of the JS thread and async operations visualizing how it works

A mental model of how the JavaScript Thread works with state, React batches state updates together

In my mental model, I see individual state values as reliable but slow. Whenever I update one, I know it can take a while for it to change.

But what happens to state and the component itself, when it’s mounting and unmounting?

A Component’s lifecycle: mental models for mounting, rendering, and unmounting

We used to talk a lot about lifecycle methods when only class-components had access to state and control of what was happening to a component over its lifetime. But since Hooks came out and allowed us the same kind of power in functional components, the idea became less relevant.

What’s interesting is that each component still has a lifecycle: its mounted, rendered and unmounted, and each step must be taken into account for a fully-functional mental model around React components.

So let’s go through each phase and build a mental model for it, I promise it’ll make your understanding of a component much better.

Mounting: Creating Components

When React creates or renders a component for the first time it’s mounting it. Meaning it’s going to be added to the DOM and React will start keeping track of it.

I like to imagine mounting as a new box being or added inside its parent.

Mounting happens whenever a component hasn’t been rendered, and its parent decides to render it for the first time. In other words, mounting is a component being “born”.

A component can be created and destroyed many times, and each time it’s created, it will be mounted again.

const Component = () => { const [show, setShow] = useState(false); return ( <div> <button onClick={() => setShow(!show)}>Show Menu</button> // Mounted with show = true and unomunted with show = false {show && <MenuDropdown />} </div> );
};

React renders components so fast it can look like its hiding them but in reality, it’s creating and deleting them very quickly. In the example above the <MenuDropdown /> component will be added and removed from the DOM every time the button is clicked.

Note how the component’s parent is the one deciding when to mount and unmount <MenuDropdown />. This goes up the hierarchy too. If MenuDropdown has children components they will be mounted or unmounted too. The component itself never knows when it’s going to be mounted or unmounted.

Two boxes next to each other representing the mental model of a React component mounting a child component when logic changes. The component-to-be-mounted is shown with low opacity on the left

The parent component re-renders with different logic, causing a child to mount

Once a component is mounted, it will do a few things:

  • Initialize useState with default values: this only happens on mount.
  • Execute the component’s logic.
  • Do an initial render, adding the elements to the DOM.
  • Run the useEffect hook.

Note that the useEffect hook runs after the initial render. That’s when you want to run code like creating event listeners, executing heavy logic, or fetching data. More on this in the useEffect section below.

My mental model for mounting is this: whenever a parent box decides a child must be created, it mounts it, then the component will do three things: assign default values to useState, run its logic, render, and execute the useEffect hook.

The mount phase is very similar to a normal re-render, with the difference being initializing useState with default values and the elements being added to the DOM for the first time. After mount the component remains in the DOM and is updated further.

Once a component is mounted it will continue to live until it unmounts, doing any amount of renders in between.

Rendering: Updating What The User Sees

I explained the rendering mental model in part 1, but let’s review it briefly as it’s an important phase.

After a component mounts, any changes to the props or state will cause it to re-render, re-executing all the code inside of it, including its children components. After each render the useEffect hook is evaluated again.

I imagine a component as a box and its ability to re-render makes it a re-usable box. Every render recycles the box, which could output different information while keeping the same state and code underneath.

Mental model of a component mounting, showing 3 boxes in 3 stages: the initial component, props/state change causing a rerender and new component

A component re-renders whenever state or props change, or its parent re-renders

Once a component’s parent decides to stop rendering a child–because of a conditional, changes in data or any other reason–the component will need to be unmounted.

Unnmountig: Deleting Components

When a component is unmounted React will remove it from the DOM and stops keeping track of it. The component is deleted including any state it had.

Like explained in the mounting phase, a component is both mounted and unmounted by its parent, and if the component, in turn, has children it will unmount those too, and the cycle repeats until the last child is reached.

In my mental model, I see this as a parent-box trashing a child-box. If you throw a container to the trash everything inside of it will also go to the trash, this includes other boxes (components), state, variables, everything.

Two boxes next to each other representing the mental model of a React component unmount a child component when logic changes. The component-to-be-unmounted is shown with low opacity on the right

The parent component re-renders with different logic, causing a child to unmount

But a component can create code outside of itself. What happens to any subscription, web socket, or event listener created by a component that will be unmounted?

The answer is nothing. Those functions run outside the component and won’t be affected by it being deleted. That’s why is important for the component to clean up after itself before unmounting.

Each function drains resources. Failing to clean them up can lead to nasty bugs, degraded performance and even security risks.

I think of these functions as gears turning outside my box. They’re set in motion when the component mounts, and they must be stopped when it unmounts.

Two boxes on the sides with gear icons in the middle. A mental model for external functions not being affected by React component unmount

A function that lives outside your code won't be removed on `unmount` on its own.

We’re able to clean up or stop these gears through the return function of useEffect. I will explain in detail in the Effect hook section.

So let’s put all the lifecycle methods into a clear mental model

The Complete Component Lifecycle Mental Model

To summarize so far: a component is just a function, props are the function’s arguments and state is a special value that React makes sure to keep consistent across renders. All components must be within other components, and each parent can have many children within it.

 A complete mental model for a simple stateful React component: a box with props coming from outside, state inside at the top, logic, and components.

A complete mental model for a simple stateful component

Each component has three phases in its lifecycle: mounting, rendering, and unmounting.

In my mental model, a component is a box and based on some logic it can decide to create or delete a child box. When it creates it a component is mounted and when it deletes it, it is unmounted.

A box mounting means it was created and executed. Here’s when useState is initialized with default values and React renders it so the user can see it, and starts keeping track of it.

The mounting phase is where we tend to connect to external services, fetch data or create event listeners.

Once mounted, whenever a box’s props or state changes it will be re-rendered, which I imagine as the box being recycled and everything but state is re-executed and re-calculated. What the user sees can change on every new render. Re-rendering is the second phase, which can happen any number of times, without limit.

Once a component’s parent decides to remove it, either because of logic, the parent itself was removed, or data changed, the component will unmount.

When a box unmounts it is thrown away, trashed with everything it contains, including children components (which in turn have their own unmount). This is where we have the chance to clean up and delete any external function we initialized in a useEffect.

The cycle of mounting, re-rendering, and unmounting can happen thousands of times in your app without you noticing. React is incredibly fast and that’s why it’s useful to keep a mental model in mind when dealing with complex components since it’s so hard to see what’s going on in real-time.

But how do we take advantage of these phases in our code? The answer is through the powerful useEffect hook.

The UseEffect Hook: Unlimited Power!

The Effect hook allows us to run side effects in our components. Whenever you’re fetching data, connecting to a service or subscription or manually manipulating the DOM, you’re performing a side effect (also called simply effect).

A side effect in the context of functions is anything that will make the function unpredictable, like data or state. A function without side-effects will be predictable and pure–you might have heard of pure functions–always doing the exact same thing as long as the inputs remain constant.

An Effect hook always runs after every render. The reason being that side effects can contain heavy logic or take time, such as fetching data, so in general they’re better off running after render.

The hook receives two arguments: the function to execute and an array with values that will be evaluated after each render, these values are called dependencies.


useEffect(() => { }); useEffect(() => { }, []); useEffect(() => { }, [a, b, c]);

Depending on the second argument you have 3 options with different behavior. The logic for each option is:

  • If not present the effect will run after every render. This option is not commonly used, but it’s useful in some situations like needing to do heavy calculations after each render.

  • With an empty array [] the effect runs only once, after mounting and the first render. This is great for one-time effects such as creating an event listener.

  • An array with values [a, b, c] makes the effect evaluate the dependencies, whenever a dependency changes the effect will run. This is useful to run effect when props or state changes, like fetching new data.

    Three colorful boxes depicting the three options of the useEffect hook's dependency array: without, empty, with values

A visual explanation of useEffect's dependency options

The dependency array gives useEffect its magic, and it’s important to use it correctly. You must include all variables used within useEffect, otherwise, the effect will reference stale values from previous renders when running, causing bugs.

The ESLint plugin eslint-plugin-react-hooks contains many useful Hooks-specific rules, including one that will warn you if you missed a dependency inside a useEffect.

My initial mental model for useEffect has it as a mini-box living inside its component, with three distinct behaviors depending on the usage of the dependency array: the effect either runs after every render if there are no dependencies, only after mount if it’s an empty array, or whenever a dependency changes if the array has values.

Three boxes representing each useEffect option inside a component box

Each useEffect lives inside the component, accessing the same info, but as its own box

There’s another important feature of useEffect, it allows us to clean up before a new effect is run, or before unmount occurs.

UseEffect during unmount: cleaning up

Every time we create a subscription, event listener or open connections we must clean them up when they’re no longer needed, otherwise, we create a memory leak and degrade the performance of our app.

This is where useEffect comes in handy. By returning a function from it we can run code before applying the next effect, or if the effect runs only once then the code runs before unmounting the component.

 useEffect(() => { const handleResize = () => setWindowWidth(window.innerWidth); window.addEventListener('resize', handleResize); return () => window.remoteEventListener('resize', handleResize);
}, []); useEffect(() => { const handleStatusChange = streamData => { setStreamData(streamData); }; streamingApi.subscribeToId(props.stream.id, handleStatusChange); return () => streamingApi.unsubscribeToId(props.stream.id, handleStatusChange);
}, [props.stream.id]);

The Complete React UseEffect Hook Mental Model

I imagine useEffect as a small box within a component, living alongside the logic of the component. This box’s code (called an effect) only runs after React has rendered the component, and it’s the perfect place to run side-effect or heavy logic.

All of useEffect’s magic comes from its second argument, the dependency array, and it can have three behaviors from it:

  • No argument: the effect runs after each render
  • Empty array: the effect only runs after the initial render, and the return function before unmount.
  • Array with values: whenever a dependency changes, the effect will run, and the return function will run before the new effect.

I hope you’ve found my mental models useful! Explaining them clearly was a challenge. If you enjoyed reading please share this article, it’s all I ask for ❤️.

This was the second part of a three part series, the next and last part will cover higher-level concepts such as React context and how to better think of your app to prevent common performance issues.

I’m planning a whole series of visual guides. Subscribing to my newsletter is the best way to know when they’re out. I only email for new, high-quality articles.

What questions do you have? I’m always available in Twitter, hit me up!

- 위키
Copyright © 2011-2024 iteam. Current version is 2.137.1. UTC+08:00, 2024-11-09 04:58
浙ICP备14020137号-1 $방문자$