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.
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.
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:
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.
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('');
// search something when a user writes on an input
const handleInput = e => {
// Save value in state and then use it to fetch new data ❌
setSearchValue(e.target.value);
fetchSearch(searchValue).then(results => {
// do something
});
};
};
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 => {
// Saving the search in a variable makes it reliable ✅
const search = e.target.value;
setSearchValue(search);
fetchSearch(search).then(results => {
// do something
});
};
};
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.
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.
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.
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.
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
.
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.
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.
// Option 1 - no dependencies
useEffect(() => {
// heavy logic that runs after each render
});
// Option 2 - empty dependencies
useEffect(() => {
// create an event listener, subscription, fetch one-time data
}, []);
// Option 3 - with dependencies
useEffect(() => {
// fetch data whenever A, B or C changes.
}, [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, aftermounting
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.
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.
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.
// This effect will run once at mount, creating an event listener
// It will execute the return function at unmount, removing the event listening, cleaning up ✅
useEffect(() => {
const handleResize = () => setWindowWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.remoteEventListener('resize', handleResize);
}, []);
// This effect will run whenever `props.stream.id` changes
useEffect(() => {
const handleStatusChange = streamData => {
setStreamData(streamData);
};
streamingApi.subscribeToId(props.stream.id, handleStatusChange);
// Unsubscribe to the current ID before running the next effect with the new ID
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!