Side Effects

15,627 words

Post contents

While you can build static websites with React, Angular, and Vue, these frameworks shine brightest when building interactive applications.

These applications come in many shapes, forms, and functionalities, but all have one thing in common: They take live input from the user and display information back to the user.

This is the key difference between a static site and an interactive one; static sites show their contents and then allow the user to navigate through the site without drastic changes to the displayed content; meanwhile, interactive sites drastically shift their displayed information based off of user input.

This difference carries through to how you build the application as well. A static site might prioritize an initial load by front-loading the HTML compilation through server-side render (SSR) or static site generation (SSG). On the other hand, the interactive app is more likely to focus on processing information passed to it to customize your experience.

As interactive apps rely so heavily on processing information based on user input, React, Angular, and Vue, all provide built-in ways of interacting, intercepting, and otherwise ingesting this information.

While each of these frameworks handles this input ingestion slightly differently, the underlying concepts are the same: All user input and output generate "Side effects", which need to be handled.

This raises more questions than it answers:

  • What is a side effect?
  • How do these frameworks handle side effects?
  • How does the browser notify the framework about side effects?
  • How do you ensure side effects are cleaned up?
  • How do you handle in-component side effects?

Let's answer these questions one by one, starting with:

What Is a Side Effect?

A side effect is when a piece of code changes or relies on state outside its local environment. When a piece of code does not contain a side effect, it is considered "pure."

A pure function is allowed to mutate state from within its local environment, while a side effect changes data outside its own environment

For example, say we have the following code:

function pureFn() {	let data = 0;	data++;	return data;}

This logic would be considered "pure," as it does not rely on external data sources. However, if we move the data variable outside the local environment and mutate it elsewhere:

let data;function increment() {	data++;}function setupData() {	data = 0;	increment();	return data;}

increment would be considered a "side effect" that mutates a variable outside its own environment.

When does this come into play in a production application?

This is a great question! A great example of this occurs in the browser with the window and document APIs.

Say we wanted to store a global counter that we use in multiple parts of the app; we might store this in window.

window.shoppingCartItems = 0;function addToShoppingCart() {	window.shoppingCartItems++;}addToShoppingCart();addToShoppingCart();addToShoppingCart(); // window.shoppingCartItems is now `3`

Because window is a global variable, mutating a value within it is a "side effect" when done inside a function, as the window variable was not declared within the function's scope.

Notice how our addToShoppingCart method isn't returning anything; instead, it's mutating the window variable as a side effect to update a global value. If we attempted to remove side effects from addToShoppingCart without introducing a new variable, we'd be left with the following:

window.shoppingCartItems = 0;function addToShoppingCart() {	// Nothing is happening here.	// No side effects? Yay.	// No functionality? Boo.}addToShoppingCart();addToShoppingCart();addToShoppingCart(); // window.shoppingCartItems is still `0`

Notice how addToShoppingCart now does nothing. To remove side effects while still retaining the functionality of incrementing a value, we'd have to both:

  1. Pass an input
  2. Return a value

With these changes, it might look something like this:

function addToShoppingCart(val) {	return val + 1;}let shoppingCartItems = 0;shoppingCartItems = addToShoppingCart(shoppingCartItems);shoppingCartItems = addToShoppingCart(shoppingCartItems);shoppingCartItems = addToShoppingCart(shoppingCartItems);// shoppingCartItems is now `3`

Because of the inherent nature of side effects, this demonstrates how all functions that don't return a new value either do nothing or have a side effect within them.

Further, because an application's inputs and outputs (combined, often called "I/O") come from the user rather than from the function itself, all I/O operations are considered "side effects". This means that in addition to non-returning functions, all the following are considered "side effects":

  • A user typing something
  • A user clicking something
  • Saving a file
  • Loading a file
  • Making a network request
  • Printing something to a printer
  • Logging a value to console

How Do Frameworks Handle Side Effects?

As mentioned previously, side effects are mission-critical for the kinds of applications that React, Angular, and Vue are all purpose-built for. To make side effect handling easier, each framework has its own method of running code on behalf of the developer when specific events occur in the component.

Component events a framework might listen for include:

The first of these component events is commonly fully transparent to most developers: User input bindings.

Take the following, for example:

const Comp = () => {	const sayHi = () => alert("Hi!");	return <button onClick={sayHi}>Say hello</button>;};

This component handles a click event (which is a user input — a side effect) and outputs an alert to the user in return (an output, another side effect).

See? Events are commonly hidden from the user when using one of these frameworks.

Let's look back at the four most common types of component side effect origin points:

  • User input events

  • A component rendering for the first time

  • A component being removed from the screen as part of a conditional render

  • A component's data or passed properties changing

While the first one was easy enough to tackle, the last three component events are often trickier to solve from a developer experience standpoint.

Often, a framework may implement an API corresponding to a developer-defined function (run by the framework) as a one-to-one matching of these events that occur during a component's lifespan. When a framework has a one-to-one mapping of function-to-lifecycle events, this mapping creates a series of APIs called "Lifecycle Methods"**. Angular and Vue both have lifecycle methods as part of their core APIs.

On the other hand, some frameworks choose to implement side effect handling without lifecycle methods. React is a key example of this, but Vue also has a non-lifecycle method of managing side effects in a component.

To explore what these side-effect handlers can do, let's look at an example of a handler that runs during a component's initial render.

Initial Render Side Effects

When we introduced components, we touched on the concept of "rendering". This occurs when a component is drawn on-screen, either when the user loads a page for the first time or when shown or hidden using a conditional render.

Say we have the following code:

const Child = () => {	return <p>I am the child</p>;};const Parent = () => {	const [showChild, setShowChild] = useState(true);	return (		<div>			<button onClick={() => setShowChild(!showChild)}>Toggle Child</button>			{showChild && <Child />}		</div>	);};

React Initial Render Demo - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { useState } from "react";const Child = () => {	return <p>I am the child</p>;};const Parent = () => {	const [showChild, setShowChild] = useState(true);	return (		<div>			<button onClick={() => setShowChild(!showChild)}>Toggle Child</button>			{showChild && <Child />}		</div>	);};createRoot(document.getElementById("root")).render(<Parent />);

Here, Child is added and removed from the DOM every time setShowChild is clicked. Let's say we wanted to add a way to call a console.log every time Child is shown on screen.

Remember, console.log outputs data to the user (albeit in a DevTools panel). As such, it's technically a side effect to call console.log.

While we could add this log inside of setShowChild, it's more likely to break when we inevitably refactor the Parent component's code. Instead, let's add a side effect handler to call console.log whenever Child is rendered.

Two Hooks handle all side effects within React components: useEffect and useLayoutEffect. Let's start by looking at useEffect:

import { useEffect } from "react";const Child = () => {	// Pass a function that React will run for you	useEffect(() => {		console.log("I am initialized");		// Pass an array of items to track changes of	}, []);	return <p>I am the child</p>;};

React Initial Render useEffect - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { useState, useEffect } from "react";const Child = () => {	// Pass a function that React will run for you	useEffect(() => {		console.log("I am initialized");		// Pass an array of items to track changes of	}, []);	return <p>I am the child</p>;};const Parent = () => {	const [showChild, setShowChild] = useState(true);	return (		<div>			<button onClick={() => setShowChild(!showChild)}>Toggle Child</button>			{showChild && <Child />}		</div>	);};createRoot(document.getElementById("root")).render(<Parent />);

Here, we're completing the task of "run console.log when Child is rendered for the first time" by allowing React to run the console.log side effect inside of useEffect. The empty array hints to React that we'd only like this function to run once — when the component initially renders.

The empty array passed to useEffect has a fair bit of nuance to it, which we'll learn about later.

We mentioned earlier that there is another hook used to handle side effects: useLayoutEffect. While useLayoutEffect is useful, it requires a bit of pre-requisite knowledge that we'll touch on throughout this chapter. Let's put it to the side and come back to it later.

As mentioned before, the framework itself calls these methods on your behalf when an internal event occurs; in this case, when Child is rendered.

Try clicking the toggle button repeatedly, and you'll see that the console.log occurs every time the Child component renders again.

Using Side Effects in Production

On top of providing a global variable that we can mutate to store values, both window and document expose a number of APIs that can be useful in an application.

Let's say that inside our component, we'd like to display the window size:

const WindowSize = () => {	const [height, setHeight] = useState(window.innerHeight);	const [width, setWidth] = useState(window.innerWidth);	return (		<div>			<p>Height: {height}</p>			<p>Width: {width}</p>		</div>	);};

React Broken Window Size - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { useState } from "react";const WindowSize = () => {	const [height, setHeight] = useState(window.innerHeight);	const [width, setWidth] = useState(window.innerWidth);	return (		<div>			<p>Height: {height}</p>			<p>Width: {width}</p>		</div>	);};createRoot(document.getElementById("root")).render(<WindowSize />);

This works to display the window size on the initial render, but what happens when the user resizes their browser?

Because we aren't listening for the change in window size, we never get an updated render with the new screen size!

Let's solve this by using window.addEventListener to handle resize events — emitted when the user changes their window size.

const WindowSize = () => {	const [height, setHeight] = useState(window.innerHeight);	const [width, setWidth] = useState(window.innerWidth);	useEffect(() => {		function resizeHandler() {			setHeight(window.innerHeight);			setWidth(window.innerWidth);		}		// This code will cause a memory leak, more on that soon		window.addEventListener("resize", resizeHandler);	}, []);	return (		<div>			<p>Height: {height}</p>			<p>Width: {width}</p>		</div>	);};

React Leaking Window Size - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { useState, useEffect } from "react";const WindowSize = () => {	const [height, setHeight] = useState(window.innerHeight);	const [width, setWidth] = useState(window.innerWidth);	useEffect(() => {		function resizeHandler() {			setHeight(window.innerHeight);			setWidth(window.innerWidth);		}		// This code will cause a memory leak, more on that soon		window.addEventListener("resize", resizeHandler);	}, []);	return (		<div>			<p>Height: {height}</p>			<p>Width: {width}</p>		</div>	);};createRoot(document.getElementById("root")).render(<WindowSize />);

Now, when we resize the browser, our values on-screen should update as well.

Event Bubbling Aside

In our introduction to components, we demonstrated that components can listen to HTML events.

What if we changed our code above to listen for the resize event that way to sidestep addEventListener?

const WindowSize = () => {	const [height, setHeight] = useState(window.innerHeight);	const [width, setWidth] = useState(window.innerWidth);	function resizeHandler() {		setHeight(window.innerHeight);		setWidth(window.innerWidth);	}	// This code doesn't work, we'll explain why soon	return (		<div onResize={resizeHandler}>			<p>Height: {height}</p>			<p>Width: {width}</p>		</div>	);};

React Broken Event Bubbling - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { useState, useEffect } from "react";const WindowSize = () => {	const [height, setHeight] = useState(window.innerHeight);	const [width, setWidth] = useState(window.innerWidth);	function resizeHandler() {		setHeight(window.innerHeight);		setWidth(window.innerWidth);	}	// This code doesn't work, we'll explain why soon	return (		<div onResize={resizeHandler}>			<p>Height: {height}</p>			<p>Width: {width}</p>		</div>	);};createRoot(document.getElementById("root")).render(<WindowSize />);

If we run this code, it will render as expected with the initial screen size, but subsequent re-renders will not update the value on the screen. This is because the resize event is only triggered on the window object (associated with the <html> tag) and does not permeate downwards towards other elements.

You see, by default, events will always "bubble" upwards in the DOM tree from their emitted position. So, if we click on a div, the click event will start from the div and bubble all the way up to the html tag.

A click event bubbling to the top of the document

We can demonstrate this inside our frameworks.

<div onClick={() => logMessage()}>	<p>		<span style={{ color: "red" }}>Click me</span> or even		<span style={{ background: "green", color: "white" }}>me</span>!	</p></div>

React Event Bubbling - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";const EventBubbler = () => {	const logMessage = () => alert("Clicked!");	return (		<div onClick={() => logMessage()}>			<p>				<span style={{ color: "red" }}>Click me</span> or even				<span style={{ background: "green", color: "white" }}>me</span>!			</p>		</div>	);};createRoot(document.getElementById("root")).render(<EventBubbler />);

If you click on the span, the click event will start from the span, bubble up to the p tag, and then finally bubble up to the div. Because we add an event listener on the div, it will run logMessage, even when clicking on the span.

This is why we don't simply utilize event binding for the resize event: It's only ever emitted directly from the html node. Because of this behavior, if we want to access the resize event inside our WindowSize component, we need to use addEventListener.

You can learn more about event bubbling, how it works, and how to overwrite it in specific instances from Mozilla Developer Network.

Cleaning up Side Effects

Let's put down the code for a moment and talk about side effects with an analogy.

Let's say you're watching a TV show on a television that lacks the ability to rewind or go forward but does have the ability to pause.

This might sound weird, but stick with me.

You're right at the peak moment of the show when suddenly your smoke alarm goes off.

"Oh no!" Your popcorn burnt in the microwave.

You have two options:

  1. Pause the show, then stop the microwave.
  2. Don't pause the show; go stop the microwave immediately.

While the second option might be the more natural reaction at a moment's notice, you'll find yourself with a problem: You just missed the big announcement in the show, and now you're left confused when you return to the TV.

Given your particular TV's lack of rewind functionality, you'd be stuck where you were without restarting the episode.

However, if you had paused the show, you would have been able to unpause once you'd turned off the microwave and see what the big reveal was.


Surely, this analogy has little to do with frontend development, right?

Ahh, but it does!

See, think of the TV as being a component in your app with a side effect. Let's use this clock component as an example:

const Clock = () => {	const [time, setTime] = useState(formatDate(new Date()));	useEffect(() => {		setInterval(() => {			console.log("I am updating the time");			setTime(formatDate(new Date()));		}, 1000);	}, []);	return <p role="timer">Time is: {time}</p>;};function formatDate(date) {	return (		prefixZero(date.getHours()) +		":" +		prefixZero(date.getMinutes()) +		":" +		prefixZero(date.getSeconds())	);}function prefixZero(number) {	if (number < 10) {		return "0" + number.toString();	}	return number.toString();}

In this example, we're calling setInterval to run a function every second. This function does two things:

  1. Updates time to include the current Date's hour, minute, and second hand in its string
  2. console.log a message

This setInterval call occurs on every Clock component render, thanks to each of the frameworks' side effect handlers.

Let's now render this Clock component inside a conditional block:

function App() {	const [showClock, setShowClock] = useState(true);	return (		<div>			<button onClick={() => setShowClock(!showClock)}>Toggle clock</button>			{showClock && <Clock />}		</div>	);}

React Broken Clock - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { useState, useEffect } from "react";const Clock = () => {	const [time, setTime] = useState(formatDate(new Date()));	useEffect(() => {		setInterval(() => {			console.log("I am updating the time");			setTime(formatDate(new Date()));		}, 1000);	}, []);	return <p role="timer">Time is: {time}</p>;};function formatDate(date) {	return (		prefixZero(date.getHours()) +		":" +		prefixZero(date.getMinutes()) +		":" +		prefixZero(date.getSeconds())	);}function prefixZero(number) {	if (number < 10) {		return "0" + number.toString();	}	return number.toString();}function App() {	const [showClock, setShowClock] = useState(true);	return (		<div>			<button onClick={() => setShowClock(!showClock)}>Toggle clock</button>			{showClock && <Clock />}		</div>	);}createRoot(document.getElementById("root")).render(<App />);

In App, we're defaulting showClock to true. This means that our Clock component will render on App's first render.

We can visually see that our clock is updating every second, but the really interesting part to us is the console.log. If we open up our browser's developer tools, we can see that it's logging every time it updates on screen as well.

However, let's toggle the Clock component a couple of times by clicking the button.

When we toggle the clock from rendering each time, it doesn't stop the console.log from running. However, when we re-render Clock, it creates a new interval of console.logs. This means that if we toggle the Clock component three times, it will run console.log three times for each update of the on-screen time.

This is really bad behavior. Not only does this mean that our computer is running more code than needed in the background, but it also means that the function that was passed to the setInterval call cannot be cleaned up by your browser. This means that your setInterval function (and all variables within it) stays in memory, which may eventually cause an out-of-memory crash if it occurs too frequently.

Moreover, this can directly impact your applications' functionality as well. Let's take a look at how that can happen:

Broken Production Code

Imagine that you are building an alarm clock application. You want to have the following functionality:

  • Show the remaining time on an alarm
  • Show a "wake up" screen
  • "Snooze" alarms for 5 minutes (temporarily reset the countdown of the timer to 5 minutes)
  • Disable alarms entirely

Additionally, let's throw in the ability to auto-snooze alarms that have been going off for 10 minutes. After all, someone in a deep sleep is more likely to wake up from a change in noise volume rather than a repeating loud noise.

Let's build that functionality now, but reduce the "minutes" to "seconds" for easier testing:

function AlarmScreen({ snooze, disable }) {	useEffect(() => {		setTimeout(() => {			// Automatically snooze the alarm			// after 10 seconds of inactivity			// In production, this would be 10 minutes			snooze();		}, 10 * 1000);	}, []);	return (		<div>			<p>Time to wake up!</p>			<button onClick={snooze}>Snooze for 5 seconds</button>			<button onClick={disable}>Turn off alarm</button>		</div>	);}function App() {	const [secondsLeft, setSecondsLeft] = useState(5);	const [timerEnabled, setTimerEnabled] = useState(true);	useEffect(() => {		setInterval(() => {			setSecondsLeft((v) => {				if (v === 0) return v;				return v - 1;			});		}, 1000);	}, []);	const snooze = () => {		// In production, this would add 5 minutes, not 5 seconds		setSecondsLeft((v) => v + 5);	};	const disable = () => {		setTimerEnabled(false);	};	if (!timerEnabled) {		return <p>There is no timer</p>;	}	if (secondsLeft === 0) {		return <AlarmScreen snooze={snooze} disable={disable} />;	}	return <p>{secondsLeft} seconds left in timer</p>;}

React Broken Alarm - StackBlitz

Edit

Files

  • src
import { createRoot } from "react-dom/client";import { useState, useEffect } from "react";function AlarmScreen({ snooze, disable }) {	useEffect(() => {		setTimeout(() => {			// Automatically snooze the alarm			// after 10 seconds of inactivity			// In production, this would be 10 minutes			snooze();		}, 10 * 1000);	}, []);	return (		<div>			<p>Time to wake up!</p>			<button onClick={snooze}>Snooze for 5 seconds</button>			<button onClick={disable}>Turn off alarm</button>		</div>	);}function App() {	const [secondsLeft, setSecondsLeft] = useState(5);	const [timerEnabled, setTimerEnabled] = useState(true);	useEffect(() => {		setInterval(() => {			setSecondsLeft((v) => {				if (v === 0) return v;				return v - 1;			});		}, 1000);	}, []);	const snooze = () => {		// In production, this would add 5 minutes, not 5 seconds		setSecondsLeft((v) => v + 5);	};	const disable = () => {		setTimerEnabled(false);	};	if (!timerEnabled) {		return <p>There is no timer</p>;	}	if (secondsLeft === 0) {		return <AlarmScreen snooze={snooze} disable={disable} />;	}	return <p>{secondsLeft} seconds left in timer</p>;}createRoot(document.getElementById("root")).render(<App />);