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."
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:
- Pass an input
- 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:
-
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
The first of these component events is commonly fully transparent to most developers: User input bindings.
Take the following, for example:
- React
- Angular
- Vue
const Comp = () => { const sayHi = () => alert("Hi!"); return <button onClick={sayHi}>Say hello</button>;};
@Component({ selector: "comp-comp", standalone: true, template: ` <button (click)="sayHi()">Say hello</button> `,})class CompComponent { sayHi() { alert("Hi!"); }}
<!-- Comp.vue --><script setup>const sayHi = () => alert("Hi!");</script><template> <button @click="sayHi()">Say hello</button></template>
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:
- React
- Angular
- Vue
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> );};
@Component({ selector: "child-comp", standalone: true, template: "<p>I am the child</p>",})class ChildComponent {}@Component({ selector: "parent-comp", standalone: true, imports: [ChildComponent], template: ` <div> <button (click)="setShowChild()">Toggle Child</button> @if (showChild) { <child-comp /> } </div> `,})class ParentComponent { showChild = true; setShowChild() { this.showChild = !this.showChild; }}
<!-- Child.vue --><template> <p>I am the child</p></template>
<!-- Parent.vue --><script setup>import { ref } from "vue";import Child from "./Child.vue";const showChild = ref(true);function setShowChild() { showChild.value = !showChild.value;}</script><template> <div> <button @click="setShowChild()">Toggle Child</button> <Child v-if="showChild" /> </div></template>
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 callconsole.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.
- React
- Angular
- Vue
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 rendering"); // Pass an array of items to track changes of }, []); return <p>I am the child</p>;};
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.
To execute code during an initial render of a component, Angular uses a method called ngOnInit
.
This function is specially named so that Angular can call it on your behalf during the "rendered" lifecycle event:
import { Component, OnInit } from "@angular/core";@Component({ selector: "child-comp", standalone: true, template: "<p>I am the child</p>",})class ChildComponent implements OnInit { ngOnInit() { console.log("I am rendering"); }}
All of Angular's lifecycle methods are prepended with ng
and add implements
to your component class.
This implements
clause helps TypeScript figure out which methods have which properties and throws an error when the related method is not included in the class.
While Angular strictly uses lifecycle methods and React uses a useEffect
hook to handle side effects, Vue does a bit of both.
Let's start by taking a look at Vue's lifecycle method of handling a side effect.
Vue's onMounted
Lifecycle Method
<!-- Child.vue --><script setup>import { onMounted } from "vue";onMounted(() => { console.log("I am rendering");});</script><template> <p>I am the child</p></template>
Here, we're importing the onMounted
lifecycle handler from the vue
import. Vue's lifecycle methods all start with an on
prefix when used inside a <script setup>
component.
Vue's watchEffect
Hook
Just like how React has a non-lifecycle method of running side effect, so too does Vue. This is done using Vue's watchEffect
and watch
APIs. Let's start with a simple example:
<!-- Child.vue --><script setup>import { watchEffect } from "vue";watchEffect(() => { console.log("I am rendering");});</script><template> <p>I am the child</p></template>
Here, we're using watchEffect
to run console.log
as soon as the Child
component renders.
Instead, watchEffect
is commonly expected to be used alongside reactive values (like ref
variables). We'll touch on how this works later on in the chapter.
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:
- React
- Angular
- Vue
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> );};
@Component({ selector: "window-size", standalone: true, template: ` <div> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div> `,})class WindowSizeComponent { height = window.innerHeight; width = window.innerWidth;}
<!-- WindowSize.vue --><script setup>const height = window.innerHeight;const width = window.innerWidth;</script><template> <div> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div></template>
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.
- React
- Angular
- Vue
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> );};
@Component({ selector: "window-size", standalone: true, template: ` <div> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div> `,})class WindowSizeComponent implements OnInit { height = window.innerHeight; width = window.innerWidth; resizeHandler = () => { this.height = window.innerHeight; this.width = window.innerWidth; }; ngOnInit() { // This code will cause a memory leak, more on that soon window.addEventListener("resize", this.resizeHandler); }}
You may notice that we're using an arrow function for
resizeHandler
. This arrow function helps us bindthis
inresizeHandler
back to theWindowSizeComponent
instance.If this sentence feels unfamiliar, no worries; I wrote an article explaining what this means.
<!-- WindowSize.vue --><script setup>import { ref, onMounted } from "vue";const height = ref(window.innerHeight);const width = ref(window.innerWidth);function resizeHandler() { height.value = window.innerHeight; width.value = window.innerWidth;}onMounted(() => { // This code will cause a memory leak, more on that soon window.addEventListener("resize", resizeHandler);});</script><template> <div> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div></template>
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
?
- React
- Angular
- Vue
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> );};
@Component({ selector: "window-size", standalone: true, template: ` <!-- This code doesn't work, we'll explain why soon --> <div (resize)="resizeHandler()"> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div> `,})class WindowSizeComponent { height = window.innerHeight; width = window.innerWidth; resizeHandler() { this.height = window.innerHeight; this.width = window.innerWidth; }}
<!-- WindowSize.vue --><script setup>import { ref } from "vue";const height = ref(window.innerHeight);const width = ref(window.innerWidth);function resizeHandler() { height.value = window.innerHeight; width.value = window.innerWidth;}</script><template> <!-- This code doesn't work, we'll explain why soon --> <div @resize="resizeHandler()"> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div></template>
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.
We can demonstrate this inside our frameworks.
- React
- Angular
- Vue
<div onClick={() => logMessage()}> <p> <span style={{ color: "red" }}>Click me</span> or even <span style={{ background: "green", color: "white" }}>me</span>! </p></div>
<div (click)="logMessage()"> <p> <span style="color: red">Click me</span> or even <span style="background: green; color: white;">me</span>! </p></div>
<div @click="logMessage()"> <p> <span style="color: red">Click me</span> or even <span style="background: green; color: white;">me</span>! </p></div>
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
.
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:
- Pause the show, then stop the microwave.
- 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:
- React
- Angular
- Vue
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();}
@Component({ selector: "clock-comp", standalone: true, template: ` <p role="timer">Time is: {{ time }}</p> `,})class ClockComponent implements OnInit { time = formatDate(new Date()); ngOnInit() { setInterval(() => { console.log("I am updating the time"); this.time = formatDate(new Date()); }, 1000); }}function formatDate(date: Date) { return ( prefixZero(date.getHours()) + ":" + prefixZero(date.getMinutes()) + ":" + prefixZero(date.getSeconds()) );}function prefixZero(number: number) { if (number < 10) { return "0" + number.toString(); } return number.toString();}
<!-- Clock.vue --><script setup>import { ref, onMounted } from "vue";const time = ref(formatDate(new Date()));onMounted(() => { setInterval(() => { console.log("I am updating the time"); time.value = formatDate(new Date()); }, 1000);});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();}</script><template> <p role="timer">Time is: {{ time }}</p></template>
In this example, we're calling setInterval
to run a function every second. This function does two things:
- Updates
time
to include the currentDate
's hour, minute, and second hand in its string 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:
- React
- Angular
- Vue
function App() { const [showClock, setShowClock] = useState(true); return ( <div> <button onClick={() => setShowClock(!showClock)}>Toggle clock</button> {showClock && <Clock />} </div> );}
@Component({ selector: "app-root", standalone: true, imports: [ClockComponent], template: ` <div> <button (click)="setShowClock(!showClock)">Toggle clock</button> @if (showClock) { <clock-comp /> } </div> `,})class AppComponent { showClock = true; setShowClock(val: boolean) { this.showClock = val; }}
<!-- App.vue --><script setup>import Clock from "./Clock.vue";import { ref } from "vue";const showClock = ref(true);function setShowClock(val) { showClock.value = val;}</script><template> <div> <button @click="setShowClock(!showClock)">Toggle clock</button> <Clock v-if="showClock" /> </div></template>
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.log
s. 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:
- React
- Angular
- Vue
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>;}
@Component({ selector: "alarm-screen", standalone: true, template: ` <div> <p>Time to wake up!</p> <button (click)="snooze.emit()">Snooze for 5 seconds</button> <button (click)="disable.emit()">Turn off alarm</button> </div> `,})class AlarmScreenComponent implements OnInit { @Output() snooze = new EventEmitter(); @Output() disable = new EventEmitter(); ngOnInit() { setTimeout(() => { // Automatically snooze the alarm // after 10 seconds of inactivity // In production this would be 10 minutes this.snooze.emit(); }, 10 * 1000); }}@Component({ selector: "app-root", standalone: true, imports: [AlarmScreenComponent], template: ` @if (!timerEnabled) { <p>There is no timer</p> } @else if (secondsLeft === 0) { <alarm-screen (snooze)="snooze()" (disable)="disable()" /> } @else { <p>{{ secondsLeft }} seconds left in timer</p> } `,})class AppComponent implements OnInit { secondsLeft = 5; timerEnabled = true; ngOnInit() { setInterval(() => { if (this.secondsLeft === 0) return; this.secondsLeft = this.secondsLeft - 1; }, 1000); } snooze() { // In production, this would add 5 minutes, not 5 seconds this.secondsLeft = this.secondsLeft + 5; } disable() { this.timerEnabled = false; }}
<!-- AlarmScreen.vue --><script setup>import { onMounted } from "vue";const props = defineProps(["snooze", "disable"]);onMounted(() => { setTimeout(() => { // Automatically snooze the alarm // after 10 seconds of inactivity // In production, this would be 10 minutes props.snooze(); }, 10 * 1000);});</script><template> <div> <p>Time to wake up!</p> <button @click="props.snooze()">Snooze for 5 seconds</button> <button @click="props.disable()">Turn off alarm</button> </div></template>
<!-- App.vue --><script setup>import AlarmScreen from "./AlarmScreen.vue";import { ref, onMounted } from "vue";const secondsLeft = ref(5);const timerEnabled = ref(true);onMounted(() => { setInterval(() => { if (secondsLeft.value === 0) return; secondsLeft.value = secondsLeft.value - 1; }, 1000);});const snooze = () => { // In production, this would add 5 minutes, not 5 seconds secondsLeft.value = secondsLeft.value + 5;};const disable = () => { timerEnabled.value = false;};</script><template> <p v-if="!timerEnabled">There is no timer</p> <AlarmScreen v-else-if="secondsLeft === 0" :snooze="snooze" :disable="disable" /> <p v-else>{{ secondsLeft }} seconds left in timer</p></template>
You'll notice that we're not using events for our Vue code sample and have instead opted to pass a function. This is because, while the other frameworks will continue to listen for events from an unmounted component, Vue does not.
This doesn't mean that Vue solves this issue for us, however. While passing a function is less common than using an output in Vue, it's not the only way to do things. Moreover, while using Vue's output to handle
snooze
anddisable
functionality solves the issue from the user's standpoint; it doesn't solve the memory leak that you're creating by not cleaning up yoursetTimeout
.To understand this a bit better, there's a section of this chapter that explains this.
Yes! It renders the seconds to countdown and then shows the AlarmScreen
as expected. Even our "auto-snooze" functionality is working as intended.
Let's test our manual "snooze" button and see if that works as expe-...
Wait, did the timer screen go from 4 seconds to 9? That's not how a countdown works!
Sure enough, if you happen to click the manual "Snooze" button right before the auto-snooze goes off, it will add an extra 5 seconds to your existing countdown.
This occurs because we never tell the AlarmScreen
's setTimeout
to stop running, even when AlarmScreen
is no longer rendered.
// AlarmScreen componentsetTimeout(() => { snooze();}, 10 * 1000);
When the above code's snooze
runs, it will add 4 seconds to the secondsLeft
variable through the App
's snooze
method.
To solve this, we simply need to tell our AlarmScreen
component to cancel the setTimeout
when it's no longer rendered. Let's look at we can do that with a side effect handler.
Unmounting
In our previous code sample, we showed that mounted side effects left unclean will cause bugs in our apps and performance headaches for our users.
Let's clean up these side effects using a handler that runs during a component's unmounting. To do this, we'll use JavaScript's clearTimeout
to remove any setTimeout
s that are left unrun:
const timeout = setTimeout(() => { // ...}, 1000);// This stops a timeout from running if unran.// Otherwise, it does nothing.clearTimeout(timeout);
Similarly, when using setInterval
, there's a clearInterval
method we can use for cleanup:
const interval = setInterval(() => { // ...}, 1000);// This stops an interval from runningclearInterval(interval);
- React
- Angular
- Vue
To run a cleanup function on React's useEffect
, return a function inside the useEffect
.
const Cleanup = () => { useEffect(() => { return () => { alert("I am cleaning up"); }; }, []); return <p>Unmount me to see an alert</p>;};
This returned function will be run whenever:
useEffect
is re-ran.- The returned function is run before the new
useEffect
instance is run.
- The returned function is run before the new
Comp
is unrendered.
It may seem like I said the same thing twice here, however
useEffect
can be run independent of a component's initial render. More on that soon.
Let's apply this returned function to our code sample previously:
function AlarmScreen({ snooze, disable }) { useEffect(() => { const timeout = setTimeout(() => { // Automatically snooze the alarm // after 10 seconds of inactivity // In production, this would be 10 minutes snooze(); }, 10 * 1000); return () => clearTimeout(timeout); }, []); // ...}function App() { // ... useEffect(() => { const timeout = setInterval(() => { setSecondsLeft((v) => { if (v === 0) return v; return v - 1; }); }, 1000); return () => clearInterval(timeout); }, []); // ...}
When we add a mounted lifecycle to Angular, we:
- Import
OnInit
- Add
OnInit
to the component'simplements
keyword - Add
ngOnInit
method to the component
To add an unmounted lifecycle method to an Angular component, we do the same steps as above, but with OnDestroy
instead:
import { Component, OnDestroy } from "@angular/core";@Component({ selector: "cleanup-comp", standalone: true, template: `<p>Unmount me to see an alert</p>`,})class CleanupComponent implements OnDestroy { ngOnDestroy() { alert("I am cleaning up"); }}
Let's apply this new lifecycle method to our code sample previously:
@Component({ selector: "alarm-screen", standalone: true, // ...})class AlarmScreenComponent implements OnInit, OnDestroy { // ... timeout: number | undefined = undefined; ngOnInit() { this.timeout = setTimeout(() => { if (this.secondsLeft === 0) return; this.secondsLeft = this.secondsLeft - 1; }, 1000); } ngOnDestroy() { clearTimeout(this.timeout); } // ...}@Component({ selector: "app-root", standalone: true, // ...})class AppComponent implements OnInit, OnDestroy { // ... interval: number | undefined = undefined; ngOnInit() { this.interval = setInterval(() => { if (this.secondsLeft === 0) return; this.secondsLeft = this.secondsLeft - 1; }, 1000); } ngOnDestroy() { clearInterval(this.interval); } // ...}
Similar to how we import onMounted
, we can import onUnmounted
in Vue to run the relevant lifecycle method.
<!-- Cleanup.vue --><script setup>import { onUnmounted } from "vue";onUnmounted(() => { alert("I am cleaning up");});</script><template> <p>Unmount me to see an alert</p></template>
Let's apply this new lifecycle method to our code sample previously:
<!-- AlarmScreen.vue --><script setup>import { ref, onMounted, onUnmounted } from "vue";const emit = defineEmits(["snooze", "disable"]);// We don't need to wrap this in `ref`, since it won't be used in `template`let timeout;onMounted(() => { timeout = setTimeout(() => { // Automatically snooze the alarm // after 10 seconds of inactivity // In production, this would be 10 minutes emit("snooze"); }, 10 * 1000);});onUnmounted(() => { clearTimeout(timeout);});</script><template> <!-- ... --></template>
<!-- App.vue --><script setup>import AlarmScreen from "./AlarmScreen.vue";import { ref, onMounted, onUnmounted } from "vue";// We don't need to wrap this in `ref`, since it won't be used in `template`let interval;onMounted(() => { interval = setInterval(() => { if (secondsLeft.value === 0) return; secondsLeft.value = secondsLeft.value - 1; }, 1000);});onUnmounted(() => { clearInterval(interval);});// ...</script><template> <!-- ... --></template>
Vue's watchEffect
Cleanup
As mentioned previously, Vue has two methods of handling side effects; Lifecycle methods and watchEffect
. Luckily, watchEffect
also has the ability to clean up side effects that were created before.
To clean up an effect, watchEffect
provides an argument to the inner watchEffect
function, which we'll name onCleanup
. This property is, in itself, a function that we call with the cleanup logic:
// onCleanup is a property passed to the inner `watchEffect` functionwatchEffect((onCleanup) => { const interval = setInterval(() => { console.log("Hello!"); }, 1000); // We then call `onCleanup` with the expected cleaning behavior onCleanup(() => { clearInterval(interval); });});
Let's rewrite the previous code samples to use watchEffect
:
<!-- App.vue --><script setup>import AlarmScreen from "./AlarmScreen.vue";import { ref, watchEffect } from "vue";const secondsLeft = ref(5);const timerEnabled = ref(true);watchEffect((onCleanup) => { const interval = setInterval(() => { if (secondsLeft.value === 0) return; secondsLeft.value = secondsLeft.value - 1; }, 1000); onCleanup(() => { clearInterval(interval); });});const snooze = () => { secondsLeft.value = secondsLeft.value + 5;};const disable = () => { timerEnabled.value = false;};</script><template> <p v-if="!timerEnabled">There is no timer</p> <AlarmScreen v-else-if="secondsLeft === 0" @snooze="snooze()" @disable="disable()" /> <p v-else>{{ secondsLeft }} seconds left in timer</p></template>
<!-- AlarmScreen.vue --><script setup>import { ref, watchEffect } from "vue";const emit = defineEmits(["snooze", "disable"]);watchEffect((onCleanup) => { const timeout = setTimeout(() => { // Automatically snooze the alarm // after 10 seconds of inactivity // In production, this would be 10 minutes emit("snooze"); }, 10 * 1000); onCleanup(() => clearTimeout(timeout));});</script><template> <div> <p>Time to wake up!</p> <button @click="emit('snooze')">Snooze for 5 seconds</button> <button @click="emit('disable')">Turn off alarm</button> </div></template>
Hidden Memory Leaks
While we've quickly glanced at how each framework is able to clean up its side effects using a side effect handler that runs when a component unmounts, we haven't seen how this impacts other aspects of a component yet.
For example, we mentioned in the first chapter that you can emit events from a child component into a parent component. Let's build out a component that uses events to trigger a parent function a second after its contents are shown.
Let's not yet introduce cleanup, you'll see why in a second.
- React
- Angular
- Vue
React isn't able to emit an event from a child component to a parent component without passing a function down from the parent to the child:
const Child = ({ fn }) => { // ...};const Parent = () => { const fn = () => { // ... }; return <Child fn={fn} />;};
However, this is not the same thing as emitting an event in Angular or Vue. We'll take a look at why that is and how it relates to a component unmounting shortly.
@Component({ selector: "app-alert", standalone: true, template: ` <p>Showing alert...</p> `,})class AlertComponent implements OnInit { @Output() alert = new EventEmitter(); ngOnInit() { // Notice that we don't clean up this side effect setTimeout(() => { this.alert.emit(); }, 1000); }}@Component({ selector: "app-root", standalone: true, imports: [AlertComponent], template: ` <div> <!-- Try clicking and unclicking quickly --> <button (click)="toggle()">Toggle</button> <!-- Binding to an event --> @if (show) { <app-alert (alert)="alertUser()" /> } </div> `,})class AppComponent { show = false; toggle() { this.show = !this.show; } alertUser() { alert("I am an alert!"); }}
<!-- Alert.vue --><script setup>import { onMounted } from "vue";const emit = defineEmits(["alert"]);onMounted(() => { setTimeout(() => { emit("alert"); }, 1000);});</script><template> <p>Showing alert...</p></template>
<!-- App.vue --><script setup>import { ref } from "vue";import Alert from "./Alert.vue";const show = ref(false);const toggle = () => (show.value = !show.value);const alertUser = () => alert("I am an alert!");</script><template> <div> <!-- Try clicking and unclicking quickly --> <button @click="toggle()">Toggle</button> <!-- Binding to an event --> <Alert v-if="show" @alert="alertUser()" /> </div></template>
Now, if we run one of our code samples and click on a button rapidly and repeatedly, we get... Nothing! Only the last alert
will show up to notify you that our Alert
component has been rendered.
That's strange... I thought that our
setTimeout
would still trigger because we haven't cleaned it up using aclearTimeout
.
You're right! The setTimeout
does trigger! You can verify this by updating our setTimeout
to include a console.log
inside of it.
Then why isn't our
alert
being triggered?
Well, when you think about how a component in one of these frameworks works, you might visualize them akin to this:
Here, we can see that despite there being one component declaration:
- React
- Angular
- Vue
const Comp = () => { const obj = {}; return <p>Hello, world</p>;};
@Component({ standalone: true, selector: "app-comp", template: `<p>Hello, world</p>`,})class CompComponent { obj = {};}
<!-- Comp.vue --><script setup>const obj = {};</script><template> <p>Hello, world</p></template>
Every time we use this component:
- React
- Angular
- Vue
const App = () => { return <div> <!-- One: --> <Comp /> <!-- Two: --> <Comp /> </div>}
@Component({ standalone: true, imports: [CompComponent], selector: "app-root", template: ` <div> <!-- One: --> <app-comp /> <!-- Two: --> <app-comp /> </div> `,})class AppComponent {}
<!-- App.vue --><script setup>import Comp from "./Comp.vue";</script><template> <div> <!-- One: --> <Comp /> <!-- Two: --> <Comp /> </div></template>
Each of the individual Comp
usages generates a component instance. These instances have their own separate memory usage, which
allows you to control the state from them both independently of one another.
Moreover, though, because each component instance has its own connection to the framework root instance, it can do some cleanup of event listeners when an instance is detached without impacting other instances:
So if the framework events are cleaned up for us, why do we need to handle and cleanup side effects manually?
Well, as we showed, our setTimeout
in our example isn't cleaned up even though the emit
output from the parent is. This means that if you have long-standing code (say, a useInterval
instead of a setTimeout
), you'll not release that part of the memory back to JavaScript's internal cleanup.
That's the definition of a memory leak: Not allowing the language to clean up old code. Eventually, over time, you may even hit an "out of memory" error, where your computer can no longer run your app or site without resetting itself.
Have the feeling you're missing something? Some of this section requires pre-existing knowledge about how computer memory works both in hardware land and how JavaScript handles memory under-the-hood. It's suggested to read these resources and come back if you're feeling confused.
Seeing Hidden Memory Leaks
Not only are the memory leaks we mentioned hidden from the user when using events, but there are ways to accidentally expose them to your user as well.
The easiest way to do this is to switch away from an event handler and towards a function being passed from the parent to the child:
- React
- Angular
- Vue
const Alert = ({ alert }) => { useEffect(() => { setTimeout(() => { alert(); }, 1000); }); return <p>Showing alert...</p>;};const App = () => { const [show, setShow] = useState(false); const alertUser = () => alert("I am an alert!"); return ( <> <button onClick={() => setShow(!show)}>Toggle</button> {show && <Alert alert={alertUser} />} </> );};
@Component({ selector: "app-alert", standalone: true, template: ` <p>Showing alert...</p> `,})class AlertComponent implements OnInit { @Input() alert!: () => void; ngOnInit() { setTimeout(() => { this.alert(); }, 1000); }}@Component({ selector: "app-root", standalone: true, imports: [AlertComponent], template: ` <button (click)="toggle()">Toggle</button> @if (show) { <app-alert [alert]="alertUser" /> } `,})class AppComponent { show = false; toggle() { this.show = !this.show; } alertUser() { alert("I am an alert!"); }}
<!-- Alert.vue --><script setup>import { onMounted } from "vue";const props = defineProps(["alert"]);onMounted(() => { setTimeout(() => { props.alert?.(); }, 1000);});</script><template> <p>Showing alert...</p></template>
<!-- App.vue --><script setup>import { ref } from "vue";import Alert from "./Alert.vue";const show = ref(false);const toggle = () => (show.value = !show.value);const alertUser = () => alert("I am an alert!");</script><template> <!-- Try clicking and unclicking quickly --> <button @click="toggle()">Toggle</button> <!-- Passing a function --> <Alert v-if="show" :alert="alertUser" /></template>
Now if we click the "Toggle" button rapidly, you'll end up with multiple alert
s back-to-back, as many times as you toggled the Alert
component. Your hidden memory leak is now visible to the user! 😱
Why does this behavior differ from listening for an event from the parent?
Well, let's look at our old example visually once again:
You may notice that instead of a two-way binding, we're now passing a function reference from the component instance to the framework and DOM. As a result of this passing (rather than listening), our framework of choice no longer has the ability to forcibly invalidate this reference. After all, a function in JavaScript is just a value that can be passed like any other.
This shift in how we're handling a parent's data (binding vs. getting a reference) means that on cleanup, the function passed to a child will not become invalid. Instead, it will persist in memory until it's no longer needed by the child component and cleaned up by JavaScript's internals:
This is why it's so important to clean up memory — it's easy to let it become leaky and accidentally shift the user's expected behavior.
Cleaning up Event Listeners
We had a code sample earlier in the chapter that relied on addEventListener
to get the window size. This code sample, you may have guessed, had a memory leak in it because we never cleaned up this event listener.
To clean up an event listener, we must remove its reference from the window
object via removeEventListener
:
const fn = () => console.log("a");window.addEventListener("resize", fn);window.removeEventListener("resize", fn);
Something to keep in mind with
removeEventListener
is that it needs to be the same function passed as the second argument to remove it from the listener.This means that inline arrow functions like this:
window.addEventListener("resize", () => console.log("a"));window.removeEventListener("resize", () => console.log("a"));
Won't work, but the following will:
const fn = () => console.log("a");window.addEventListener("resize", fn);window.removeEventListener("resize", fn);
Let's fix our WindowSize
component from before by cleaning up the event listener side effect using the knowledge we have now.
- React
- Angular
- Vue
const WindowSize = () => { const [height, setHeight] = useState(window.innerHeight); const [width, setWidth] = useState(window.innerWidth); useEffect(() => { function resizeHandler() { setHeight(window.innerHeight); setWidth(window.innerWidth); } window.addEventListener("resize", resizeHandler); return () => window.removeEventListener("resize", resizeHandler); }, []); return ( <div> <p>Height: {height}</p> <p>Width: {width}</p> </div> );};
@Component({ selector: "window-size", standalone: true, template: ` <div> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div> `,})class WindowSizeComponent implements OnInit, OnDestroy { height = window.innerHeight; width = window.innerWidth; // This must be an arrow function, see below for more resizeHandler = () => { this.height = window.innerHeight; this.width = window.innerWidth; }; ngOnInit() { window.addEventListener("resize", this.resizeHandler); } ngOnDestroy() { window.removeEventListener("resize", this.resizeHandler); }}
Here, we're making sure to use an arrow function for resizeHandler
in order to make sure that removeEventListener
works as expected.
To learn more about why that is, read this article I wrote about this topic.
Using lifecycles, this would be:
<!-- WindowSize.vue --><script setup>import { ref, onMounted, onUnmounted } from "vue";const height = ref(window.innerHeight);const width = ref(window.innerWidth);function resizeHandler() { height.value = window.innerHeight; width.value = window.innerWidth;}onMounted(() => { window.addEventListener("resize", resizeHandler);});onUnmounted(() => { window.removeEventListener("resize", resizeHandler);});</script><template> <div> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div></template>
Or, using watchEffect
, this is:
<!-- WindowSize.vue --><script setup>import { ref, watchEffect } from "vue";const height = ref(window.innerHeight);const width = ref(window.innerWidth);function resizeHandler() { height.value = window.innerHeight; width.value = window.innerWidth;}watchEffect((onCleanup) => { window.addEventListener("resize", resizeHandler); onCleanup(() => window.removeEventListener("resize", resizeHandler));});</script><template> <div> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div></template>
Ensuring Side Effect Cleanup
Some frameworks have taken extra steps to ensure your side effects are always cleaned up.
- React
- Angular
- Vue
When React 16.3 came out, it introduced a new component called StrictMode
.
StrictMode
was developed to help warn developers of potential problems that lie dormant in their applications. It's commonly enabled in most production codebases and is used at the root of the app like so:
import { StrictMode } from "react";import { createRoot } from "react-dom/client";const App = () => { // ...};createRoot(document.getElementById("root")).render( <StrictMode> <App /> </StrictMode>,);
Specifically, StrictMode
helps find issues with:
- Unsafe Hooks usage
- Legacy API usage
- Unexpected side effects
It does so by modifying slight behaviors of your app and printing errors when needed.
StrictMode
only does this on development builds of your app and does not impact your code whatsoever during production.
Since React 18, StrictMode
will re-run all useEffect
s twice. This change was made by the React team to highlight potential bugs in your application that are caused by uncleaned side effects.
So, if you have the following code:
let i = 0;const App = () => { useEffect(() => { alert(`I am rendering. Counter: ${++i}`); }, []); // ...};createRoot(document.getElementById("root")).render( <StrictMode> <App /> </StrictMode>,);
You'd see two alert
s when the component renders:
I am rendering. Counter: 1
I am rendering. Counter: 2
However, if you disable StrictMode
your output will be:
I am rendering. Counter: 1
Again, this is intentional. React is trying to help you find bugs in your code by highlighting side effects that are not cleaned up.
If you have code that does not work with StrictMode
, this is most likely the culprit, and you should investigate all side effect cleanups in your components.
Angular does not have any special behaviors with OnInit
to force component cleanup. React, however, does.
Vue does not have any special behaviors with OnInit
to force component cleanup. React, however, does.
Re-Renders
While rendering and un-rendering are the most frequent times you might want to add a side effect handler, they're not the only times you're able to do so.
While many of the examples we've shown before have been relatively consistent between each framework, this is where these frameworks tend to diverge. This difference occurs because the APIs exposed by each framework typically depend on how their internals function; this allows you to have more fine-grained control over your components.
While we'll touch on the framework's internals in the third book of the series, for now, let's take a look at one more component API that's relatively consistent between most frameworks: Re-rendering.
Re-rendering is what it sounds like; While the initial "render" is what allows us to see the first contents on screen being drawn, subsequent updates — like our live-updated values — are drawn during "re-renders."
Re-renders may occur for many reasons:
- Props being updated
- State being changed
- Explicitly calling a re-render via other means
Let's take a look at how each framework exposes re-rendering to you.
- React
- Angular
- Vue
Up until this point, we've only ever passed an empty array to useEffect
:
useEffect(() => { doSomething(); // This doesn't have to be an empty array}, []);
This empty array hints to React to "only run this side effect once: when the component is first rendered".
React may choose in specific instances, such as
StrictMode
, to ignore this hint. Because of this,useEffect
's array should be treated as a performance optimization for React, not a steadfast rule.
Let's take a look at the inverse of this "never run this function again" React hint:
useEffect(() => { doSomething(); // Notice no array});
Here, we're not passing an array to useEffect
, which tells the framework that it can (and should) run the side effect on every single render, regardless of if said render has updated the DOM or not.
See, not every re-render of a component triggers an on-screen change. Some re-renders will update values and re-run the function body of a component but not update the screen.
While all renders have a state comparison step, like the one that occurs to validate if the useEffect
array has changed, only renders that update the value on-screen have a "paint" step. This "paint" updates the values on-screen to the user.
Given this, the following code will re-render but not paint when the user clicks the button
.
const ReRenderListener = () => { const [_, updateState] = useState(0); useEffect(() => { console.log("Component has re-rendered"); }); // Notice the lack of an array return <button onClick={() => updateState((v) => v + 1)}>Re-render</button>;};
Because the button
triggers a re-render, useEffect
will run, even if there is not a paint.
You may think we're done with
useEffect
now, but there's yet another usage for the passed array, which we'll touch on shortly.
We mentioned earlier that outside the basics of "rendering" and "un-rendering," each framework tends to diverge. Well, dear reader, it's coming into play here.
Angular does not have a lifecycle method specifically for when a component re-renders. This is because Angular does not track DOM changes internally the same way the other two frameworks do.
This isn't to say that Angular components don't re-draw the DOM — we've already demonstrated that it's able to live-refresh the DOM when data changes — just that Angular doesn't provide a lifecycle for detecting when it does.
To answer "why" this occurs is a much longer topic, which I've written about in a dedicated blog post. In the meantime, feel free to see how the other two frameworks work as a reference to what you might expect elsewhere.
<!-- ReRenderListener.vue --><script setup>import { ref, onUpdated } from "vue";const count = ref(0);const add = () => { count.value++;};onUpdated(() => { console.log("Component was painted");});</script><template> <button @click="add()">{{ count }}</button></template>
Every time the ReRenderListener
component updates the DOM with new changes, the onUpdated
method will run.
In-Component Property Side Effects
Up to this point, we've looked at component-wide events such as "rendering the component" and "unrendering the component."
While these events are undeniably helpful to hook into, most user input doesn't cause this drastic of a change.
For example, let's say we wanted to update the browser tab's title when we select a new document:
- React
- Angular
- Vue
const App = () => { const [title, setTitle] = useState("Movies"); return ( <div> <button onClick={() => setTitle("Movies")}>Movies</button> <button onClick={() => setTitle("Music")}>Music</button> <button onClick={() => setTitle("Documents")}>Documents</button> <p>{title}</p> </div> );};
@Component({ selector: "app-root", standalone: true, template: ` <div> <button (click)="title = 'Movies'">Movies</button> <button (click)="title = 'Music'">Music</button> <button (click)="title = 'Documents'">Documents</button> <p>{{ title }}</p> </div> `,})class AppComponent { title = "Movies";}
<!-- App.vue --><script setup>import { ref, watchEffect } from "vue";const title = ref("Movies");</script><template> <div> <button @click="title = 'Movies'">Movies</button> <button @click="title = 'Music'">Music</button> <button @click="title = 'Documents'">Documents</button> <p>{{ title }}</p> </div></template>
Here, title
is a variable that is being updated, which triggers a re-render. Because the framework knows how to trigger a re-render based on a reactive change, it also has the ability to trigger a side effect whenever a value is changed.
Let's see how we do this in React, Angular, and Vue:
- React
- Angular
- Vue
Earlier in the chapter, we looked at how an empty array passed to useEffect
hints to React that you only want the inner function running once. We also saw how passing no array will cause useEffect
's function to run in every re-render.
These are two ends of an extreme; the middle-ground comes in the form of passing specific functions to your useEffect
:
const App = () => { const [title, setTitle] = useState("Movies"); useEffect(() => { document.title = title; // Ask React to only run this `useEffect` if `title` has changed }, [title]); return ( <div> <button onClick={() => setTitle("Movies")}>Movies</button> <button onClick={() => setTitle("Music")}>Music</button> <button onClick={() => setTitle("Documents")}>Documents</button> <p>{title}</p> </div> );};
By doing this, we're hinting to React that this side effect should only ever run when the test
variable's reference has changed during a render.
Stale Values
When using a component's variables within useEffect
, it's absolutely imperative that we include all the variables within the useEffect
array.
This is because any variables left outside of useEffect
will likely result in "stale" data.
"Stale" data is any data out-of-date from the "true"/intended value. This occurs when you pass data to a function inside a "closure" and do not update the value later.
Take the following code sample:
function App() { const [count, setCount] = useState(0); useEffect(() => { const interval = setInterval(() => { console.log("Count is: " + count); }, 1000); return () => clearInterval(interval); }, []); return ( <div> {count} <button onClick={() => setCount(count + 1)}>Add</button> </div> );}
Here, we're telling React to console.log
the count
value every second inside a setInterval
.
However, because we're not passing count
to the useEffect
array, the console.log
will never show any value other than:
"Count is: 0";
This is because our useEffect
has a "stale" value of count
and React is never telling the function to update with the new count
value.
To solve this, we can add count
to the useEffect
array:
useEffect(() => { const interval = setInterval(() => { console.log("Count is: " + count); }, 1000); return () => clearInterval(interval);}, [count]);
Today, Angular does not include a method for tracking internal state changes. However, a future version of Angular will introduce the concept of "Signals", which will allow us to watch changes made to a variable, regardless of where the state change comes from.
Instead, we'll have to use a setTitle
function that calls the variable mutation as well as sets the document.title
as a side effect:
@Component({ selector: "app-root", standalone: true, template: ` <div> <button (click)="title = setTitles('Movies')">Movies</button> <button (click)="title = setTitles('Music')">Music</button> <button (click)="title = setTitles('Documents')">Documents</button> <p>{{ title }}</p> </div> `,})class AppComponent { setTitles(val: string) { document.title = val; return val; } title = this.setTitles("Movies");}
I have to come clean about something: watchEffect
isn't primarily used to run an effect on a component's first render, as we've been using it to this point.
Instead, watchEffect
does something pretty magical: It re-runs the inner function whenever a "tracked" ref
is updated:
<!-- App.vue --><script setup>import { ref, watchEffect } from "vue";const title = ref("Movies");// watchEffect will re-run whenever `title.value` is updatedwatchEffect(() => { document.title = title.value;});</script><template> <div> <button @click="title = 'Movies'">Movies</button> <button @click="title = 'Music'">Music</button> <button @click="title = 'Documents'">Documents</button> <p>{{ title }}</p> </div></template>
How does watchEffect
know what refs to watch? The long answer dives deep into Vue's source code and is a challenge to introduce at this stage.
The short answer is "Vue's ref
code contains a bit of logic inside it to register to the nearest watchEffect
, but only when ref
is inside a synchronous operation."
This means that if we have the following code:
const title = ref("Movies");const count = ref(0);// watchEffect will re-run whenever `title.value` or `count.value` is updatedwatchEffect(() => { console.log(count.value); document.title = "Title is " + title.value + " and count is " + count.value;});
The watchEffect
will run whenever title
or count
is updated. However, if we do the following:
const title = ref("Movies");const count = ref(0);// watchEffect will re-run only when `count.value` is updatedwatchEffect(() => { console.log(count.value); // This is an async operation, inner `ref` usage won't be tracked setTimeout(() => { document.title = "Title is " + title.value + " and count is " + count.value; }, 0);});
It will only track changes to count
, as the title.value
usage is inside an async operation.
Manually Track Changes with watch
watchEffect
is a bit magic, aye? Maybe it's a bit too much for your needs, maybe you need to be more specific about what you do and do not track. This is where Vue's watch
function comes into play.
While watchEffect
seemingly magically detects what variables to listen to, watch
requires you to be explicit about what properties to listen for changes on:
import { ref, watch } from "vue";const title = ref("Movies");watch( // Only listen for changes to `title`, despite what's in the function body title, () => { document.title = title.value; }, { immediate: true },);
You may notice that we're passing
{immediate: true}
as the options for thewatch
; what is that doing?Well, by default
watch
only runs after the first render. This means that the initial state oftitle
would otherwise not trigger thedocument.title
update.For example:
watch(title, () => { document.title = title.value;});
Would still run the inner function when
title
has changed, but only after the initial render.
watch
Multiple Items
To watch multiple items, we can pass an array of reactive variables:
const title = ref("Movies");const count = ref(0);// This will run when `title` and `count` are updated, despite async usagewatch( [title, count], () => { setTimeout(() => { document.title = "Title is " + title.value + " and count is " + count.value; }, 0); }, { immediate: true },);
watch
also supports passing an onCleanup
method to clean up watched side effects, much like the watchEffect
API:
const title = ref("Movies");const count = ref(0);// This will run when `title` and `count` are updated, despite async usagewatch([title, count], (currentValue, previousValue, onCleanup) => { const timeout = setTimeout(() => { document.title = "Title is " + title.value + " and count is " + count.value; }, 1000); onCleanup(() => clearTimeout(timeout));});
Rendering, Committing, Painting
While we might attribute the definition of "rendering" to mean "showing something new on-screen," this is only partially true. While Angular follows this definition to some degree, React and Vue do not.
Instead, React and Vue both have a trick up their sleeves; the virtual DOM (VDOM). While explaining the VDOM is a bit complex, here's the basics:
-
The framework mirrors the nodes in the DOM tree so that it can recreate the entire app's UI at any given moment.
-
When you tell the framework to update the value on the screen, it tries to figure out the specific part of the screen to render and nothing more.
-
After the framework has decided specifically which elements it wants to re-render with new contents, it will:
- Create a set of instructions that are needed to run to update the DOM
- Run those instructions on the VDOM
- Reflect the changes made in VDOM on the actual browser DOM
- The browser then takes the updates made to the DOM and shows them to the user
Let's pause on these four steps of the last bullet point here. This process of deciding which elements the framework needs to update is called "reconciliation." This reconciliation step has three parts to it and are named respectively:
- Diffing
- Pre-committing
- Committing
- Painting
Keep in mind, this "reconciliation" process occurs as part of a render. Your component may render due to reactive state changes, but may not trigger the entire reconciliation process if it detects nothing has changed during the
diffing
stage.
React and Vue both provide a way to access internal stages of reconciliation with their own APIs.
- React
- Angular
- Vue
Early in this article, we mentioned there were two ways of handling side effects in React: useEffect
and useLayoutEffect
. While we've sufficiently covered the usage of useEffect
, we haven't yet explored useLayoutEffect
.
There are two big differences between useEffect
and useLayoutEffect
:
- While
useEffect
runs after a component's paint,useLayoutEffect
occurs before the component's paint but after** a component's commit phase. useLayoutEffect
blocks the browser, whichuseEffect
does not.
If useLayoutEffect
only ran prior to the browser's paint, it might be acceptable to use it more frequently. However, because it does block the browser from painting, it should only be used in very specific circumstances.
For example, let's say you want to measure the size of an HTML element and display that information as part of the UI; you'd want to use useLayoutEffect
instead of useEffect
, as otherwise, it would flash the contents on screen momentarily before the component re-rendered to hide the initialization data.
Let's use useLayoutEffect
to calculate the bounding box of an element to position another element:
import { useState, useLayoutEffect } from "react";function App() { const [num, setNum] = useState(10); const [bounding, setBounding] = useState({ left: 0, right: 0, top: 0, bottom: 0, height: 0, }); // This runs before the DOM paints useLayoutEffect(() => { // This should be using a `ref`. More on that in a future chapter const el = document.querySelector("#number"); const b = el?.getBoundingClientRect(); setBounding(b); }, [num]); return ( <div> <input type="number" value={num} onChange={(e) => setNum(e.target.valueAsNumber || "0")} /> <div style={{ display: "flex", justifyContent: "flex-end" }}> <h1 id="number" style={{ display: "inline-block" }}> {num} </h1> </div> <h1 style={{ position: "absolute", left: bounding.left, top: bounding.top + bounding.height, }} > ^ </h1> </div> );}
While the initial value is set to 10
with an arrow pointing to the 1
, if we change this value to 1000
, it will move the arrow underneath the 1
, without flashing an instance of the arrow not facing the 1
:
Because Angular does not use a virtual DOM, it does not have a method to detect specific parts of the reconciliation process.
We've been using watch
and watchEffect
throughout the course of this chapter, primarily as a means to listen to global events of some kind.
However, what if we wanted to localize our side effects to an element on-screen as part of a component's child? Let's try it:
<!-- App.vue --><script setup>import { ref, watch } from "vue";const title = ref("Movies");watch( title, () => { const el = document.querySelector("#title-paragraph"); alert(el?.innerText); }, { immediate: true },);</script><template> <div> <button @click="title = 'Movies'">Movies</button> <button @click="title = 'Music'">Music</button> <button @click="title = 'Documents'">Documents</button> <p id="title-paragraph">{{ title }}</p> </div></template>
Here, when we click any of the buttons to trigger a title
change, you may notice that it shows the previous value of the element's innerText
. For example, when we press "Music", it shows the innerText
of Movies
, which was the previous value of title
.
That doesn't seem to follow the behavior we're looking for!
There's another hint that things aren't working as-expected either; on the first immediate run of watch
Why is this happening?
See, by default, both watch
and watchEffect
run before the DOM's contents have been committed from Vue's VDOM.
To change this behavior, we can add {flush: 'post'}
to either watch
or watchEffect
to run the watcher's function after the DOM commit phase.
watch( title, () => { const el = document.querySelector("#title-paragraph"); alert(el?.innerText); }, { immediate: true, flush: "post" },);
Now, when we click on an item, it will print out the current version of title
for the element's innerText
.
While this level of internals knowledge is seldom used when getting started building applications, it can provide you the powerful ability to optimize and improve your applications; think of this information as your developer superpower.
Like any other superpower, you should use these last few APIs with care, knowing that they may make your application worse rather than better; With great APIs comes great responsibility.
Changing Data without Renderings
Sometimes, it's not ideal to trigger a re-render every time you want to set a variable's state.
For example, let's go back to our document.title
example. Say that instead of updating the title
and document.title
right away, we want to delay the updating of both using a setTimeout
:
- React
- Angular
- Vue
const TitleChanger = () => { const [title, setTitle] = useState("Movies"); function updateTitle(val) { const timeout = setTimeout(() => { setTitle(val); document.title = val; }, 5000); } return ( <div> <button onClick={() => updateTitle("Movies")}>Movies</button> <button onClick={() => updateTitle("Music")}>Music</button> <button onClick={() => updateTitle("Documents")}>Documents</button> <p>{title}</p> </div> );};
@Component({ selector: "title-changer", standalone: true, template: ` <div> <button (click)="updateTitle('Movies')">Movies</button> <button (click)="updateTitle('Music')">Music</button> <button (click)="updateTitle('Documents')">Documents</button> <p>{{ title }}</p> </div> `,})export class TitleChangerComponent { title = "Movies"; updateTitle(val: string) { setTimeout(() => { this.title = val; document.title = val; }, 5000); }}
<!-- TitleChanger.vue --><script setup>import { ref } from "vue";const title = ref("Movies");function updateTitle(val) { setTimeout(() => { title.value = val; document.title = val; }, 5000);}</script><template> <div> <button @click="updateTitle('Movies')">Movies</button> <button @click="updateTitle('Music')">Music</button> <button @click="updateTitle('Documents')">Documents</button> <p>{{ title }}</p> </div></template>
If we click one of these buttons and un-render the App
component, our setTimeout
will still execute because we've never told this component to cancel the timeout.
We could solve this problem using a stateful variable:
- React
- Angular
- Vue
const TitleChanger = () => { const [title, setTitle] = useState("Movies"); const [timeoutExpire, setTimeoutExpire] = useState(null); function updateTitle(val) { clearTimeout(timeoutExpire); const timeout = setTimeout(() => { setTitle(val); document.title = val; }, 5000); setTimeoutExpire(timeout); } useEffect(() => { return () => clearTimeout(timeoutExpire); }, [timeoutExpire]); return ( <div> <button onClick={() => updateTitle("Movies")}>Movies</button> <button onClick={() => updateTitle("Music")}>Music</button> <button onClick={() => updateTitle("Documents")}>Documents</button> <p>{title}</p> </div> );};
@Component({ selector: "title-changer", standalone: true, template: ` <div> <button (click)="updateTitle('Movies')">Movies</button> <button (click)="updateTitle('Music')">Music</button> <button (click)="updateTitle('Documents')">Documents</button> <p>{{ title }}</p> </div> `,})export class TitleChangerComponent implements OnDestroy { title = "Movies"; timeoutExpire: any = null; updateTitle(val: string) { clearTimeout(this.timeoutExpire); this.timeoutExpire = setTimeout(() => { this.title = val; document.title = val; }, 5000); } ngOnDestroy() { clearTimeout(this.timeoutExpire); }}
<!-- TitleChanger.vue --><script setup>import { ref, onUnmounted } from "vue";const title = ref("Movies");const timeoutExpire = ref(null);function updateTitle(val) { clearTimeout(timeoutExpire.value); timeoutExpire.value = setTimeout(() => { title.value = val; document.title = val; }, 5000);}onUnmounted(() => clearTimeout(timeoutExpire.value));</script><template> <div> <button @click="updateTitle('Movies')">Movies</button> <button @click="updateTitle('Music')">Music</button> <button @click="updateTitle('Documents')">Documents</button> <p>{{ title }}</p> </div></template>
This will trigger a re-render of App
when we run updateTitle
. This re-render will not display any new changes since our timeoutExpire
property is not used in the DOM but may be computationally expensive depending on the size of your App
component.
Luckily for us, each framework has the ability to sidestep a render while persisting a value in a component's state.
- React
- Angular
- Vue
To store a variable's state in a React function component without triggering a re-render, we can use a useRef
hook to store our setTimeout
return without triggering a re-render:
import { useState, useRef, useEffect } from "react";const TitleChanger = () => { const [title, setTitle] = useState("Movies"); const timeoutExpire = useRef(null); function updateTitle(val) { timeoutExpire.current = setTimeout(() => { setTitle(val); document.title = val; }, 5000); } useEffect(() => { return () => clearTimeout(timeoutExpire.current); }, [timeoutExpire]); return ( <div> <button onClick={() => updateTitle("Movies")}>Movies</button> <button onClick={() => updateTitle("Music")}>Music</button> <button onClick={() => updateTitle("Documents")}>Documents</button> <p>{title}</p> </div> );};
useRef
allows you to persist data across renders, similar to useState
. There are two major differences from useState
:
- You access data from a ref using
.current
- It does not trigger a re-render when updating values (more on that soon)
This makes useRef
perfect for things like setTimeout
and setInterval
returned values; they need to be persisted to clean up properly but do not need to be displayed to the user, so we can avoid re-rendering.
useRef
s Don't Trigger useEffect
s
Because useRef
doesn't trigger a re-render, our useEffect
will never re-run; useEffect
doesn't listen to the passed array values but rather checks the reference of the array's value.
What does this mean?
Take the following JavaScript:
const obj1 = { updated: false };const obj2 = obj1;obj1.updated = true;console.log("Is object 2 updated?", obj2.updated); // trueconsole.log("Is object 1 and 2 the same?", obj1 === obj2); // true
This code snippet demonstrates how you can mutate a variable's value without changing its underlying memory location.
The useRef
hook is implemented under the hood similar to the following:
const useRef = (initialValue) => { const [value, _] = useState({ current: initialValue }); return value;};
Because the updates to useRef
do not trigger the second argument of useState
, it mutates the underlying object rather than referentially changes the object, which would trigger a re-render.
Now, let's see how this fundamental change impacts our usage of useRef
. Take the following code sample:
const Comp = () => { const ref = useRef(); useEffect(() => { ref.current = Date.now(); }); // First render won't have `ref.current` set return <p>The current timestamp is: {ref.current}</p>;};
Why doesn't this show a timestamp?
This is because when you change ref
it never causes a re-render, which then never re-draws the p
.
Here, useRef
is set to undefined
and only updates after the initial render in the useEffect
, which does not cause a re-render.
To solve this, we must set a useState
to trigger a re-render.
const Comp = () => { // Set initial value for first render const ref = useRef(Date.now()); // We're not using the `_` value, just the `set` method to force a re-render const [_, setForceRenderNum] = useState(0); useEffect(() => { ref.current = Date.now(); }); return ( <div> {/* This value will only update when you press "check timestamp" */} <p>The current timestamp is: {ref.current}</p> <button onClick={() => setForceRenderNum((v) => v + 1)}> Check timestamp </button> </div> );};
Here, the timestamp display will never update until you press the button
. Even then, however, useEffect
will run after the render, meaning that the displayed timestamp will be from the previous occurrence of the button
press.
While writing this book, dear reader, I had a few goals. One of those goals was to not introduce an API without first explaining it. I am about to break this rule for the only time I'm aware of in this book. Please forgive me.
While the internals of how Angular is able to detect changes in values are complex, the simple answer is "It uses some magic in something called Zone.js to automatically detect when you change a value."
To sidestep this detection from Zone.js in Angular, you can tell the framework to run something "outside of Angular."
To do this, we need to use "Dependency Injection" to access Angular's internal NgZone
reference and use the runOutsideAngular
method:
import { Component, NgZone, OnDestroy, inject } from "@angular/core";@Component({ selector: "title-changer", standalone: true, template: ` <div> <button (click)="updateTitle('Movies')">Movies</button> <button (click)="updateTitle('Music')">Music</button> <button (click)="updateTitle('Documents')">Documents</button> <p>{{ title }}</p> </div> `,})export class TitleChangerComponent implements OnDestroy { title = "Movies"; timeoutExpire: any = null; // This is using "Dependency Injection" (chapter 11) // To access Angular's internals and expose them to you ngZone = inject(NgZone); updateTitle(val: string) { clearTimeout(this.timeoutExpire); // Do not run this in runOutsideAngular, otherwise `title` will never update const expire = setTimeout(() => { this.title = val; document.title = val; }, 5000); this.ngZone.runOutsideAngular(() => { this.timeoutExpire = expire; }); } ngOnDestroy() { clearTimeout(this.timeoutExpire); }}
If
inject
seems magic to you, it might as well be. To explore how dependency injection works under-the-hood, check out chapter 11, which explores the topic.
Because Vue uses a script
that only executes once per component run, we can rely on mutating the variable's value using the let
variable keyword.
<!-- TitleChanger.vue --><script setup>import { watch, ref, onUnmounted } from "vue";const title = ref("Movies");let timeoutExpire = null;function updateTitle(val) { clearTimeout(timeoutExpire); timeoutExpire = setTimeout(() => { title.value = val; document.title = val; }, 5000);}onUnmounted(() => clearTimeout(timeoutExpire));</script><template> <div> <button @click="updateTitle('Movies')">Movies</button> <button @click="updateTitle('Music')">Music</button> <button @click="updateTitle('Documents')">Documents</button> <p>{{ title }}</p> </div></template>
API Chart
Let's take a look visually at how each framework calls the relevant APIs we've touched on today:
- React
- Angular
- Vue
Because Vue has two different APIs, I made two charts for them.
Vue Lifecycle Methods
Vue Watchers
Challenge
Since the start of this book, we've been working on a storage app. While our mockups to this point have been using a light mode, which is an important default for all apps due to accessibility concerns, let's add in a dark mode for the app:
This can be done by using a combination of technologies:
- A manual theme toggles with three options:
- Light Mode
- Inherit Mode from OS
- Dark Mode
- CSS variables
- The browser's
matchMedia
API detects the operating system's theme. - Persist the user's theme selection with
localstorage
Let's start by building out the theme toggle using our respective frameworks.
- React
- Angular
- Vue
function DarkModeToggle() { const [explicitTheme, setExplicitTheme] = React.useState("inherit"); return ( <div style={{ display: "flex", gap: "1rem" }}> <label style={{ display: "inline-flex", flexDirection: "column" }}> <div>Light</div> <input name="theme" type="radio" checked={explicitTheme === "light"} onChange={() => setExplicitTheme("light")} /> </label> <label style={{ display: "inline-flex", flexDirection: "column" }}> <div>Inherit</div> <input name="theme" type="radio" checked={explicitTheme === "inherit"} onChange={() => setExplicitTheme("inherit")} /> </label> <label style={{ display: "inline-flex", flexDirection: "column" }}> <div>Dark</div> <input name="theme" type="radio" checked={explicitTheme === "dark"} onChange={() => setExplicitTheme("dark")} /> </label> </div> );}
@Component({ selector: "dark-mode-toggle", standalone: true, template: ` <div style="display: flex; gap: 1rem"> <label style="display: inline-flex; flex-direction: column"> <div>Light</div> <input name="theme" type="radio" [checked]="explicitTheme === 'light'" (change)="setExplicitTheme('light')" /> </label> <label style="display: inline-flex; flex-direction: column"> <div>Inherit</div> <input name="theme" type="radio" [checked]="explicitTheme === 'inherit'" (change)="setExplicitTheme('inherit')" /> </label> <label style="display: inline-flex; flex-direction: column"> <div>Dark</div> <input name="theme" type="radio" [checked]="explicitTheme === 'dark'" (change)="setExplicitTheme('dark')" /> </label> </div> `,})class DarkModeToggleComponent { explicitTheme = "inherit"; setExplicitTheme(val: string) { this.explicitTheme = val; }}
<!-- DarkModeToggle.vue --><script setup>import { ref } from "vue";const explicitTheme = ref("inherit");</script><template> <div style="display: flex; gap: 1rem"> <label style="display: inline-flex; flex-direction: column"> <div>Light</div> <input name="theme" type="radio" :checked="explicitTheme === 'light'" @change="explicitTheme = 'light'" /> </label> <label style="display: inline-flex; flex-direction: column"> <div>Inherit</div> <input name="theme" type="radio" :checked="explicitTheme === 'inherit'" @change="explicitTheme = 'inherit'" /> </label> <label style="display: inline-flex; flex-direction: column"> <div>Dark</div> <input name="theme" type="radio" :checked="explicitTheme === 'dark'" @change="explicitTheme = 'dark'" /> </label> </div></template>
Now that we have this theme toggle let's make the dark
mode work by using some CSS and a side effect handler to listen for changes to value
:
- React
- Angular
- Vue
function DarkModeToggle() { const [explicitTheme, setExplicitTheme] = React.useState("inherit"); React.useEffect(() => { document.documentElement.className = explicitTheme; }, [explicitTheme]); // ...}function App() { return ( <div> <DarkModeToggle /> <p style={{ color: "var(--primary)" }}>This text is blue</p> <style children={` :root { --primary: #1A42E5; } .dark { background: #121926; color: #D6E4FF; --primary: #6694FF; } `} /> </div> );}
import { Component, ViewEncapsulation } from "@angular/core";@Component({ selector: "dark-mode-toggle", standalone: true, // ...})class DarkModeToggleComponent { explicitTheme = "inherit"; setExplicitTheme(val: string) { this.explicitTheme = val; document.documentElement.className = val; }}@Component({ selector: "app-root", standalone: true, imports: [DarkModeToggleComponent], // This allows our CSS to be global, rather than limited to the component encapsulation: ViewEncapsulation.None, styles: [ ` :root { --primary: #1a42e5; } .dark { background: #121926; color: #d6e4ff; --primary: #6694ff; } `, ], template: ` <div> <dark-mode-toggle /> <p style="color: var(--primary)">This text is blue</p> </div> `,})class AppComponent {}
<!-- DarkModeToggle.vue --><script setup>import { ref, watch } from "vue";const explicitTheme = ref("inherit");watch(explicitTheme, () => { document.documentElement.className = explicitTheme.value;});</script><template> <!-- ... --></template>
<!-- App.vue --><script setup>import DarkModeToggle from "./DarkModeToggle.vue";</script><template> <div> <DarkModeToggle /> <p style="color: var(--primary)">This text is blue</p> </div></template><style>:root { --primary: #1a42e5;}.dark { background: #121926; color: #d6e4ff; --primary: #6694ff;}</style>
Now, we can use the matchMedia
API to add a check if the user's OS has changed its theme or not.
See, we can detect the user's preferred color theme by doing the following in JavaScript:
// If true, the user prefers dark modewindow.matchMedia("(prefers-color-scheme: dark)").matches;
We can even add a listener for when the user changes this preference real time by doing the following:
window .matchMedia("(prefers-color-scheme: dark)") .addEventListener("change", (e) => { // If true, the user prefers dark mode e.matches; });
Now that we know the JavaScript API let's integrate it with our application:
- React
- Angular
- Vue
const isOSDark = window.matchMedia("(prefers-color-scheme: dark)");function DarkModeToggle() { const [explicitTheme, setExplicitTheme] = useState("inherit"); const [osTheme, setOSTheme] = useState(isOSDark.matches ? "dark" : "light"); useEffect(() => { if (explicitTheme === "implicit") { document.documentElement.className = osTheme; return; } document.documentElement.className = explicitTheme; }, [explicitTheme, osTheme]); useEffect(() => { const changeOSTheme = () => { setOSTheme(isOSDark.matches ? "dark" : "light"); }; isOSDark.addEventListener("change", changeOSTheme); return () => { isOSDark.removeEventListener("change", changeOSTheme); }; }, []); // ...}
@Component({ selector: "dark-mode-toggle", standalone: true, // ...})class DarkModeToggleComponent implements OnInit, OnDestroy { explicitTheme = "inherit"; isOSDark = window.matchMedia("(prefers-color-scheme: dark)"); osTheme = this.isOSDark.matches ? "dark" : "light"; // Remember, this has to be an arrow function, not a method changeOSTheme = () => { this.setExplicitTheme(this.isOSDark.matches ? "dark" : "light"); }; ngOnInit() { this.isOSDark.addEventListener("change", this.changeOSTheme); } ngOnDestroy() { this.isOSDark.removeEventListener("change", this.changeOSTheme); } setExplicitTheme(val: string) { this.explicitTheme = val; if (val === "implicit") { document.documentElement.className = val; return; } document.documentElement.className = val; }}
<!-- DarkModeToggle.vue --><script setup>import { ref, watch, onMounted, onUnmounted } from "vue";const explicitTheme = ref("inherit");watch(explicitTheme, () => { if (explicitTheme.value === "implicit") { document.documentElement.className = explicitTheme.value; return; } document.documentElement.className = explicitTheme.value;});const isOSDark = window.matchMedia("(prefers-color-scheme: dark)");const changeOSTheme = () => { explicitTheme.value = isOSDark.matches ? "dark" : "light";};onMounted(() => { isOSDark.addEventListener("change", changeOSTheme);});onUnmounted(() => { isOSDark.removeEventListener("change", changeOSTheme);});</script><template> <!-- ... --></template>
Now when the user changes their operating system's dark mode settings, it will reflect through our website.
Finally, let's remember the preference the user selected in the manual toggle by using the browser's localstorage
API. This API works like a JSON object that persists through multiple browser sessions. This means we can do the following in JavaScript:
// Will be `null` if nothing is definedconst car = localStorage.getItem("car");if (!car) localStorage.setItem("car", "Hatchback");
Let's integrate this in our DarkModeToggle
component:
- React
- Angular
- Vue
const isOSDark = window.matchMedia("(prefers-color-scheme: dark)");function DarkModeToggle() { const [explicitTheme, setExplicitTheme] = useState( localStorage.getItem("theme") || "inherit", ); const [osTheme, setOSTheme] = useState(isOSDark.matches ? "dark" : "light"); useEffect(() => { localStorage.setItem("theme", explicitTheme); }, [explicitTheme]); useEffect(() => { if (explicitTheme === "implicit") { document.documentElement.className = osTheme; return; } document.documentElement.className = explicitTheme; }, [explicitTheme, osTheme]); useEffect(() => { const changeOSTheme = () => { setOSTheme(isOSDark.matches ? "dark" : "light"); }; isOSDark.addEventListener("change", changeOSTheme); return () => { isOSDark.removeEventListener("change", changeOSTheme); }; }, []); return ( <div style={{ display: "flex", gap: "1rem" }}> <label style={{ display: "inline-flex", flexDirection: "column" }}> <div>Light</div> <input name="theme" type="radio" checked={explicitTheme === "light"} onChange={() => setExplicitTheme("light")} /> </label> <label style={{ display: "inline-flex", flexDirection: "column" }}> <div>Inherit</div> <input name="theme" type="radio" checked={explicitTheme === "inherit"} onChange={() => setExplicitTheme("inherit")} /> </label> <label style={{ display: "inline-flex", flexDirection: "column" }}> <div>Dark</div> <input name="theme" type="radio" checked={explicitTheme === "dark"} onChange={() => setExplicitTheme("dark")} /> </label> </div> );}function App() { return ( <div> <DarkModeToggle /> <p style={{ color: "var(--primary)" }}>This text is blue</p> <style children={` :root { --primary: #1A42E5; } .dark { background: #121926; color: #D6E4FF; --primary: #6694FF; } `} /> </div> );}
Final code output
@Component({ selector: "dark-mode-toggle", standalone: true, template: ` <div style="display: flex; gap: 1rem"> <label style="display: inline-flex; flex-direction: column"> <div>Light</div> <input name="theme" type="radio" [checked]="explicitTheme === 'light'" (change)="setExplicitTheme('light')" /> </label> <label style="display: inline-flex; flex-direction: column"> <div>Inherit</div> <input name="theme" type="radio" [checked]="explicitTheme === 'inherit'" (change)="setExplicitTheme('inherit')" /> </label> <label style="display: inline-flex; flex-direction: column"> <div>Dark</div> <input name="theme" type="radio" [checked]="explicitTheme === 'dark'" (change)="setExplicitTheme('dark')" /> </label> </div> `,})class DarkModeToggleComponent implements OnInit, OnDestroy { explicitTheme = localStorage.getItem("theme") || "inherit"; isOSDark = window.matchMedia("(prefers-color-scheme: dark)"); osTheme = this.isOSDark.matches ? "dark" : "light"; // Remember, this has to be an arrow function, not a method changeOSTheme = () => { this.setExplicitTheme(this.isOSDark.matches ? "dark" : "light"); }; ngOnInit() { this.isOSDark.addEventListener("change", this.changeOSTheme); } ngOnDestroy() { this.isOSDark.removeEventListener("change", this.changeOSTheme); } setExplicitTheme(val: string) { this.explicitTheme = val; localStorage.setItem("theme", val); if (val === "implicit") { document.documentElement.className = val; return; } document.documentElement.className = val; }}@Component({ selector: "app-root", standalone: true, imports: [DarkModeToggleComponent], // This allows our CSS to be global encapsulation: ViewEncapsulation.None, styles: [ ` :root { --primary: #1a42e5; } .dark { background: #121926; color: #d6e4ff; --primary: #6694ff; } `, ], template: ` <div> <dark-mode-toggle /> <p style="color: var(--primary)">This text is blue</p> </div> `,})class AppComponent {}
Final code output
<!-- DarkModeToggle.vue --><script setup>import { ref, watch, onMounted, onUnmounted } from "vue";const explicitTheme = ref(localStorage.getItem("theme") || "inherit");watch(explicitTheme, () => { localStorage.setItem("theme", explicitTheme);});watch(explicitTheme, () => { if (explicitTheme.value === "implicit") { document.documentElement.className = explicitTheme.value; return; } document.documentElement.className = explicitTheme.value;});const isOSDark = window.matchMedia("(prefers-color-scheme: dark)");const changeOSTheme = () => { explicitTheme.value = isOSDark.matches ? "dark" : "light";};onMounted(() => { isOSDark.addEventListener("change", changeOSTheme);});onUnmounted(() => { isOSDark.removeEventListener("change", changeOSTheme);});</script><template> <div style="display: flex; gap: 1rem"> <label style="display: inline-flex; flex-direction: column"> <div>Light</div> <input name="theme" type="radio" :checked="explicitTheme === 'light'" @change="explicitTheme = 'light'" /> </label> <label style="display: inline-flex; flex-direction: column"> <div>Inherit</div> <input name="theme" type="radio" :checked="explicitTheme === 'inherit'" @change="explicitTheme = 'inherit'" /> </label> <label style="display: inline-flex; flex-direction: column"> <div>Dark</div> <input name="theme" type="radio" :checked="explicitTheme === 'dark'" @change="explicitTheme = 'dark'" /> </label> </div></template>
<!-- App.vue --><script setup>import DarkModeToggle from "./DarkModeToggle.vue";</script><template> <div> <DarkModeToggle /> <p style="color: var(--primary)">This text is blue</p> </div></template><style>:root { --primary: #1a42e5;}.dark { background: #121926; color: #d6e4ff; --primary: #6694ff;}</style>