Post contents
We've previously touched on how to pass values to a component as properties earlier in the book:
- React
- Angular
- Vue
const FileDate = ({ inputDate }) => { const [dateStr, setDateStr] = useState(formatDate(inputDate)); const [labelText, setLabelText] = useState(formatReadableDate(inputDate)); return <span aria-label={labelText}>{dateStr}</span>;};
import { Component, OnInit } from "@angular/core";@Component({ selector: "file-date", standalone: true, template: `<span [attr.aria-label]="labelText">{{ dateStr }}</span>`,})class FileDateComponent implements OnInit { @Input() inputDate!: Date; dateStr = ""; labelText = ""; ngOnInit() { this.dateStr = this.formatDate(this.inputDate); this.labelText = this.formatReadableDate(this.inputDate); } // ...}
<!-- FileDate.vue --><script setup>// ...const props = defineProps(["inputDate"]);const dateStr = ref(formatDate(props.inputDate));const labelText = ref(formatReadableDate(props.inputDate));// ...</script><template> <span :aria-label="labelText">{{ dateStr }}</span></template>
You may notice that we're deriving two values from the same property. This works fine at first, but an issue arises with how we're doing things when we realize that our formatDate
and formatReadableDate
methods are only running once during the initial render.
Because of this, if we pass in an updated inputDate
to the FileDate
component, the values of formatDate
and formatReadableDate
will become out of sync from the parent's passed inputDate
.
- React
- Angular
- Vue
const File = ({ href, fileName, isSelected, onSelected, isFolder }) => { const [inputDate, setInputDate] = useState(new Date()); useEffect(() => { // Check if it's a new day every 10 minutes const timeout = setTimeout( () => { const newDate = new Date(); if (inputDate.getDate() === newDate.getDate()) return; setInputDate(newDate); }, 10 * 60 * 1000, ); return () => clearTimeout(timeout); }, [inputDate]); // JSX shortened for focus // This may not show the most up-to-date `formatDate` or `formatReadableDate` return <FileDate inputDate={inputDate} />;};
@Component({ selector: "file-item", standalone: true, imports: [FileDateComponent], template: ` <!-- ... --> <!-- This may not show the most up-to-date 'formatDate' or 'formatReadableDate' --> @if (!isFolder) { <file-date [inputDate]="inputDate" /> } <!-- ... --> `,})class FileComponent implements OnInit, OnDestroy { // ... inputDate = new Date(); interval: any = null; ngOnInit() { // Check if it's a new day every 10 minutes this.interval = setInterval( () => { const newDate = new Date(); if (this.inputDate.getDate() === newDate.getDate()) return; this.inputDate = newDate; }, 10 * 60 * 1000, ); } ngOnDestroy() { clearInterval(this.interval); }}
<!-- File.vue --><script setup>import { ref, onMounted, onUnmounted } from "vue";import FileDate from "./FileDate.vue";// ...const inputDate = ref(new Date());const interval = ref(null);onMounted(() => { // Check if it's a new day every 10 minutes interval.value = setInterval( () => { const newDate = new Date(); if (inputDate.value.getDate() === newDate.getDate()) return; inputDate.value = newDate; }, 10 * 60 * 1000, );});onUnmounted(() => { clearInterval(interval.value);});</script><template> <!-- ... --> <!-- This may not show the most up-to-date `formatDate` or `formatReadableDate` --> <FileDate v-if="isFolder" :inputDate="inputDate" /> <!-- ... --></template>
While the above File
component updates inputDate
correctly, our FileDate
component is never listening for the changed input value and, as such, never recomputed the formatDate
or formatReadableDate
value to display to the user.
How can we fix this?
Method 1: Prop Listening
The first - and arguably easiest to mentally model - method to solve this disparity between prop value and display value is to simply listen for when a property's value has been updated and re-calculate the display value.
Luckily, we can use our existing knowledge of side effects to do so:
- React
- Angular
- Vue
const FileDate = ({ inputDate }) => { const [dateStr, setDateStr] = useState(formatDate(inputDate)); const [labelText, setLabelText] = useState(formatReadableDate(inputDate)); useEffect(() => { setDateStr(formatDate(inputDate)); setLabelText(formatReadableDate(inputDate)); // Every time `inputDate` changes, it'll trigger a render and therefore call the `useEffect` }, [inputDate]); return <span aria-label={labelText}>{dateStr}</span>;};
While we didn't touch on this lifecycle method in our previous chapter, Angular has a lifecycle method specifically for when a component's props change: ngOnChanges
.
We can use this new lifecycle method to update the value of a component's state based off of the parent's props:
import { Component, OnChanges, SimpleChanges } from "@angular/core";@Component({ selector: "file-date", standalone: true, template: `<span [attr.aria-label]="labelText">{{ dateStr }}</span>`,})class FileDateComponent implements OnChanges { @Input() inputDate: Date; dateStr = ""; labelText = ""; // Notice that we no longer need `ngOnInit` ngOnChanges(changes: SimpleChanges) { /** * ngOnChanges runs for EVERY prop change. As such, we can * restrict the recalculation to only when `inputDate` changes */ if (changes["inputDate"]) { this.dateStr = this.formatDate(this.inputDate); this.labelText = this.formatReadableDate(this.inputDate); } } // ...}
<!-- FileDate.vue --><script setup>import { ref, watch } from "vue";// ...const props = defineProps(["inputDate"]);const dateStr = ref(formatDate(props.inputDate));const labelText = ref(formatReadableDate(props.inputDate));watch( () => props.inputDate, (newDate, oldDate) => { dateStr.value = formatDate(newDate); labelText.value = formatReadableDate(newDate); },);</script><template> <span :aria-label="labelText">{{ dateStr }}</span></template>
Vue's watch
logic allows you to track a given property or state value changes based on its key.
Here, we're watching the inputDate
props key and, when changed, updating dateStr
and labelText
based off of the new property value.
While this method works, it tends to introduce duplicate developmental logic. For example, notice how we have to repeat the declaration of the dateStr
and labelText
values twice: Once when they're initially defined and again inside the property listener.
Luckily for us, there's an easy solution for this problem called "computed values."
Method 2: Computed Values
Our previous method of deriving a value from a property follows two steps:
- Set an initial value
- Update and recompute the value when its base changes
However, what if we could instead simplify this idea to a single step:
- Run a function over a value and live update as it changes.
This may remind you of a similar pattern we've used already for live updated text and attribute binding.
Luckily for us, all three frameworks have a way of doing just this!
- React
- Angular
- Vue
import { useMemo } from "react";const FileDate = ({ inputDate }) => { const dateStr = useMemo(() => formatDate(inputDate), [inputDate]); const labelText = useMemo(() => formatReadableDate(inputDate), [inputDate]); return <span aria-label={labelText}>{dateStr}</span>;};
useMemo
is a method for computing values based on an input or series of inputs. This works because it does the computation and regenerates the calculation whenever the second argument array's values have changed in a render.
Like useEffect
, this array's values' changes are only tracked when the component is rendered. Unlike useEffect
, however, there's no option to remove the second array argument entirely.
Instead, if you want to recalculate the logic in every render, you'd remove the useMemo
entirely. So, for simple computations, you can take this code:
const AddComp = ({ baseNum, addNum }) => { const val = useMemo(() => baseNum + addNum, [baseNum, addNum]); return <p>{val}</p>;};
And refactor it to look like this:
const AddComp = ({ baseNum, addNum }) => { const val = baseNum + addNum; return <p>{val}</p>;};
While it's technically possible to use this trick to never use
useMemo
, your application's performance will suffer drastically. That said, it's a bit of a science to know when and where to useuseMemo
. We'll touch on this more in our third book titled "Internals".
To solve the derived value problem without recomputing the values manually, Angular introduces the concept of a "pipe" into the mix of things. The idea is that a pipe runs over an input (or series of inputs), just like React's useMemo
.
import { Pipe, PipeTransform } from "@angular/core";@Pipe({ name: "formatDate", standalone: true })class FormatDatePipe implements PipeTransform { transform(value: Date): string { return formatDate(value); }}@Pipe({ name: "formatReadableDate", standalone: true })class FormatReadableDatePipe implements PipeTransform { transform(value: Date): string { return formatReadableDate(value); }}
You may then use these pipes in your components directly inside the template.
@Component({ selector: "file-date", standalone: true, imports: [FormatReadableDatePipe, FormatDatePipe], template: ` <span [attr.aria-label]="inputDate | formatReadableDate"> {{ inputDate | formatDate }} </span> `,})class FileDateComponent { @Input() inputDate!: Date;}
Multiple Input Pipes
You may notice the similarities between pipes and functions. After all, pipes are effectively functions you're able to call in your template. Much like functions, they're not limited to a single input property, either.
Let's add a second input to see if the formatDate
pipe should return a readable date or not.
@Pipe({ name: "formatDate", standalone: true })class FormatDatePipe implements PipeTransform { // `dateFormat` is an optional argument. If left empty, will simply `formatDate` transform(value: Date, dateFormat?: string): string { // Stands for "Long format month, day of month, year" if (dateFormat === "MMMM d, Y") return formatReadableDate(value); return formatDate(value); }}
Then, we can use it in our template while passing a second argument:
@Component({ selector: "file-date", standalone: true, imports: [FormatDatePipe], template: ` <span [attr.aria-label]="inputDate | formatDate: 'MMMM d, Y'"> {{ inputDate | formatDate }} </span> `,})class FileDateComponent { @Input() inputDate: Date;}
Built-In Pipes
Luckily, Angular's all-in-one methodology means that there's a slew of pipes that the Angular team has written for us. One such pipe is actually a date formatting pipe. We can remove our own implementation in favor of one built right into Angular!
To use the built-in pipes, we need to import them from CommonModule
into the component. In this case, the pipe we're looking to use is called DatePipe
. This provided date pipe is, expectedly, called date
when used in the template and can be used like so:
import { DatePipe } from "@angular/common";@Component({ selector: "file-date", standalone: true, imports: [DatePipe], template: ` <span [attr.aria-label]="inputDate | date: 'MMMM d, Y'"> {{ inputDate | date }} </span> `,})class FileDateComponent { @Input() inputDate!: Date;}
<!-- FileDate.vue --><script setup>import { computed } from "vue";// ...const props = defineProps(["inputDate"]);const dateStr = computed(() => formatDate(props.inputDate));const labelText = computed(() => formatReadableDate(props.inputDate));</script><template> <span :aria-label="labelText">{{ dateStr }}</span></template>
Instead of using ref
to construct a set of variables and then re-initializing the values once we watch
a prop
, we can simply tell Vue to do that same process for us using computed
props.
Vue is able to ✨ magically ✨ detect what data inside the computed
function is dynamic, just like watchEffect
. When this dynamic data is changed, it will automatically re-initialize the variable it's assigned to with a new value returned from the inner function.
These computed
props are then accessible in the same way a data
property is, both from the template and from Vue's <script>
alike.
Non-Prop Derived Values
While we've primarily used component inputs to demonstrate derived values today, both of the methods we've used thus far work for the internal component state and inputs.
Let's say that we have a piece of state called number
in our component and want to display the doubled value of this property without passing this state to a new component:
- React
- Angular
- Vue
const CountAndDoubleComp = () => { const [number, setNumber] = useState(0); const doubleNum = useMemo(() => number * 2, [number]); return ( <div> <p>{number}</p> <p>{doubleNum}</p> <button onClick={() => setNumber(number + 2)}>Add one</button> </div> );};
@Pipe({ name: "doubleNum", standalone: true })class DoubleNumPipe implements PipeTransform { transform(value: number): number { return value * 2; }}@Component({ selector: "count-and-double", standalone: true, imports: [DoubleNumPipe], template: ` <div> <p>{{ number }}</p> <p>{{ number | doubleNum }}</p> <button (click)="addOne()">Add one</button> </div> `,})class CountAndDoubleComponent { number = 0; addOne() { this.number++; }}
<!-- CountAndDouble.vue --><script setup>import { ref, computed } from "vue";const number = ref(0);function addOne() { number.value++;}const doubleNum = computed(() => number.value * 2);</script><template> <div> <p>{{ number }}</p> <p>{{ doubleNum }}</p> <button @click="addOne()">Add one</button> </div></template>
In this component, we can see two numbers — one doubling the value of the other. We then have a button that allows us to increment the first number, and therefore, using a derived value, the second number also updates.
Challenge
While building through our continued file hosting application, let's think through how our Size
can be calculated to be displayed in the UI like so:
File sizes are usually measured in how many bytes it takes to store the file. However, this isn't exactly useful information past a certain size. Let's instead use the following JavaScript to figure out how large a file size is, given the number of bytes:
const kilobyte = 1024;const megabyte = kilobyte * 1024;const gigabyte = megabyte * 1024;function formatBytes(bytes) { if (bytes < kilobyte) { return `${bytes} B`; } else if (bytes < megabyte) { return `${Math.floor(bytes / kilobyte)} KB`; } else if (bytes < gigabyte) { return `${Math.floor(bytes / megabyte)} MB`; } else { return `${Math.floor(bytes / gigabyte)} GB`; }}
Fun code challenge for you at home — can you write the above in fewer lines of code? 🤔
With this JavaScript, we can use a derived value to display the relevant display size. Let's build this out using a dedicated DisplaySize
component:
- React
- Angular
- Vue
function DisplaySize({ bytes }) { const humanReadableSize = useMemo(() => formatBytes(bytes), [bytes]); return <p>{humanReadableSize}</p>;}const kilobyte = 1024;const megabyte = kilobyte * 1024;const gigabyte = megabyte * 1024;function formatBytes(bytes) { if (bytes < kilobyte) { return `${bytes} B`; } else if (bytes < megabyte) { return `${Math.floor(bytes / kilobyte)} KB`; } else if (bytes < gigabyte) { return `${Math.floor(bytes / megabyte)} MB`; } else { return `${Math.floor(bytes / gigabyte)} GB`; }}
Final code output
@Pipe({ name: "formatBytes", standalone: true })class FormatBytesPipe implements PipeTransform { kilobyte = 1024; megabyte = this.kilobyte * 1024; gigabyte = this.megabyte * 1024; transform(bytes: number): string { if (bytes < this.kilobyte) { return `${bytes} B`; } else if (bytes < this.megabyte) { return `${Math.floor(bytes / this.kilobyte)} KB`; } else if (bytes < this.gigabyte) { return `${Math.floor(bytes / this.megabyte)} MB`; } else { return `${Math.floor(bytes / this.gigabyte)} GB`; } }}@Component({ selector: "display-size", standalone: true, imports: [FormatBytesPipe], template: `<p>{{ bytes | formatBytes }}</p>`,})class DisplaySizeComponent { @Input() bytes!: number;}
Final code output
<!-- DisplaySize.vue --><script setup>import { computed } from "vue";const props = defineProps(["bytes"]);const humanReadableSize = computed(() => formatBytes(props.bytes));const kilobyte = 1024;const megabyte = kilobyte * 1024;const gigabyte = megabyte * 1024;function formatBytes(bytes) { if (bytes < kilobyte) { return `${bytes} B`; } else if (bytes < megabyte) { return `${Math.floor(bytes / kilobyte)} KB`; } else if (bytes < gigabyte) { return `${Math.floor(bytes / megabyte)} MB`; } else { return `${Math.floor(bytes / gigabyte)} GB`; }}</script><template> <p>{{ humanReadableSize }}</p></template>