Component Fundamentals with JavaScript Frameworks
On day 26, I review the code of AlertBar
component and spot two improvements to make it cleaner.
- The component has a static label and a select element that two-way bind to the
AlertList
component. It can be extracted to aAlertDropdown
component. - The
AlertList
andAlertBar
components have logic to manage the state of theclosedNotifications
ref. The logic and the ref can be encapsulated in a state management solution.
Framework | State Management |
---|---|
Vue | Composable |
Angular | Service |
Svelte | $state in Store |
Create an AlertDropDown component
Vue 3 application
<script setup lang="ts">
type Props = {
label: string
items: { value: string, text: string }[]
}
const { label, items } = defineProps<Props>()
const selectedValue = defineModel<string>('selectedValue')
</script>
<template>
<span>{{ label }} </span>
<select class="select select-info mr-[0.5rem]" v-model="selectedValue">
<option v-for="{value, text} in items" :key="value" :value="value">
{{ text }}
</option>
</select>
</template>
The Props
type has a string label and an array of items. Use defineModel
to create a selectedValue
ref that binds to the parent component.
SvelteKit application
<script lang="ts">
type Props = {
label: string;
items: { text: string, value: string }[];
selectedValue: string;
}
let { label, items, selectedValue = $bindable() }: Props = $props();
</script>
<span>{ label } </span>
<select class="select select-info mr-[0.5rem]" bind:value={selectedValue}>
{#each items as item (item.value) }
<option value={item.value}>
{ item.text }
</option>
{/each}
</select>
The Props
type has a label
string, a selectedValue
string and an items
array. Use the $bindable
macro to bind the selectedValue
to the parent component.
Angular 20 application
import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-alert-dropdown',
imports: [FormsModule],
template: `
<span>{{ label() }} </span>
<select class="select select-info mr-[0.5rem]" [(ngModel)]="selectedValue">
@for (style of items(); track style.value) {
<option [ngValue]="style.value">
{{ style.text }}
</option>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AlertDropdownComponent {
label = input.required<string>();
items = input.required<{ text: string, value: string }[]>();
selectedValue = model.required<string>();
}
label
and items
are required signals. selectedValue
is a model Writable signal that binds the value with the parent component.
Refactor the AlertBar Component
Refactor the AlertBar
component to use AlertDropdown
to reduce 8 - 10 lines of code.
Vue 3 application
Apply the AlertDropdown
component to replace the HTML select elements and static lables.
<template>
<p class="mb-[0.75rem]">
<span>Has close button? </span>
<input type="checkbox" class="mr-[0.5rem]" v-model="hasCloseButton" />
<AlertDropdown :label="config.styleLabel" :items="config.styles" v-model:selectedValue="style" />
<AlertDropdown :label="config.directionLabel" :items="config.directions" v-model:selectedValue="direction" />
</p>
</template>
For the style dropdown, the label
prop receives the config.styleLabel
and the items
prop receives the config.styles
array. Moreover, the v-model
is updated to v-model:selectedValue
to bind to the style
ref.
For the direction dropdown, the label
prop receives the config.directionLabel
and the items
prop receives the config.directions
array. Moreover, the v-model
is updated to v-model:selectedValue
to bind to the direction
ref.
SvelteKit application
<p class="mb-[0.75rem]">
<span>Has close button?</span>
<input type="checkbox" class="mr-[0.5rem]" bind:checked={hasCloseButton} />
<AlertDropdown label={configs.styleLabel} items={configs.styles} bind:selectedValue={style} />
<AlertDropdown label={configs.directionLabel} items={configs.directions} bind:selectedValue={direction} />
</p>
For the style dropdown, the label
prop receives the config.styleLabel
and the items
prop receives the config.styles
array. Moreover, the bind:value
is updated to bind:selectedValue
to bind to the style
rune.
For the direction dropdown, the label
prop receives the config.directionLabel
and the items
prop receives the config.directions
array. Moreover, the bind:value
is updated to bind:selectedValue
to bind to the direction
rune.
Angular 20 application
<p class="mb-[0.75rem]">
<span>Has close button? </span>
<input type="checkbox" class="mr-[0.5rem]" [(ngModel)]="hasCloseButton" />
<app-alert-dropdown [label]="c.styleLabel" [items]="c.styles" [(selectedValue)]="style" />
<app-alert-dropdown [label]="c.directionLabel" [items]="c.directions" [(selectedValue)]="direction" />
</p>
For the style dropdown, the label
input receives the c.styleLabel
and the items
input receives the c.styles
array. Moreover, the [(ngModel)]
is updated to [(selectedValue)]
to bind to the style
model.
For the direction dropdown, the label
input receives the c.directionLabel
and the items
input receives the c.directions
array. Moreover, the [(ngModel)]
is updated to [(selectedValue)]
to bind to the direction
model.
Extract the logic of Closed Notification
The AlertList
and AlertBar
have a few small functions to perform add, remove, clear, and retrieve closed notifications. Therefore, I want to extract these functions into a new file and invoke the functions in the components.
The closedNotification
is converted into shareable data, allowing both AlertList
and AlertBar
to access it.
Vue 3 application
Create a useNotifications
composable under composables
directory
import { readonly, ref } from "vue"
const closedNotifications = ref<string[]>([])
export function useNotifications() {
function remove(type: string) {
closedNotifications.value = closedNotifications.value.filter((t) => t !== type)
}
function removeAll() {
closedNotifications.value = []
}
function isNonEmpty() {
return closedNotifications.value.length > 0
}
function add(type: string) {
closedNotifications.value.push(type)
}
return {
closedNotifications: readonly(closedNotifications),
remove,
clearAll,
isNonEmpty,
add
}
}
closedNotifications
is a shared ref between the components.
The useNotifications
composable has functions for CRUD.
Name | Purpose |
---|---|
remove | remove a notification's type |
removeAll | remove all notification types |
isNonEmpty | check the array has a closed notification |
add | append the type of the closed notification |
closedNotification
is readonly, so it prevents accidental mutation in the component code.
SvelteKit application
Create a new file under the stores
directory. Svelte 4 uses store to maintain state but runes is capable of doing simple state management in Svelte 5.
const state = $state({
closedNotifications: [] as string[]
});
const closedNotifications = $derived(() => state.closedNotifications);
export function getClosedNotification() {
return closedNotifications;
}
export function removeNotification(type: string) {
state.closedNotifications = state.closedNotifications.filter((t) => t !== type);
}
export function removeAllNotifications() {
state.closedNotifications = [];
}
export function isNotEmpty() {
return state.closedNotifications.length > 0
}
export function addNotification(type: string) {
state.closedNotifications.push(type);
}
Svelte compiler logs "Cannot export state from a module if it is reassigned" error in the terminal, so state
is an Object rune that has a closedNotifications
array. Otherwise, I cannot modify closedNotifications
in the removeNotification
function.
Name | Purpose |
---|---|
removeNotification | remove a notification's type |
removeAllNotifications | remove all notification types |
isNonEmpty | check the array has a closed notification |
addNotification | append the type of the closed notification |
getClosedNotification | return the readonly closedNotification rune |
closedNotifications
is a readonly rune that stores a function that returns the closedNotifications
array.
const a = getClosedNotification()
assigns the rune to a
. a()
evaluates to the reactive closedNotifications
for rendering in a template.
Angular 20 application
Create a NotificationsService
and inject the service into the components later.
import { Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class NotificationsService {
#closedNotifications = signal<string[]>([]);
closedNotifications = this.#closedNotifications.asReadonly();
remove(type: string) {
this.#closedNotifications.update((prev) => prev.filter((t) => t !== type));
}
removeAll() {
this.#closedNotifications.set([])
}
isNonEmpty() {
return this.#closedNotifications().length > 0;
}
add(type: string) {
this.#closedNotifications.update((prev) => ([...prev, type ]));
}
}
#closedNotifications
is a signal that stores an array of string. The asReadonly
method of Signal
class creates a readonly signal. Therefore, closedNotifications
is a readonly signal used for rendering in a inline template.
Name | Purpose |
---|---|
remove | remove a notification's type |
removeAll | remove all notification types |
isNonEmpty | check the array has a closed notification |
add | append the type of the closed notification |
Apply the Notification Logic to the AlertBar Component
Vue 3 application
The new logic is applied to AlertBar
and AlertList
componebts.
In the AlertBar
component,
import { useNotifications } from '@/composables/useNotification'
const { closedNotifications, removeAll, isNonEmpty, remove } = useNotifications()
Import useNotifications
and destructure the functions. Then, delete the previously declaredclosedNotification
ref. Then, update the buttons with the function calls.
<button v-for="type in closedNotifications"
:key="type"
@click="remove(type)"
>
<OpenIcon />{{ capitalize(type) }}
</button>
Replace removeNotifcation
with remove
and no change to the v-for
directive.
<button
v-if="isNonEmpty()"
class="btn btn-primary"
@click="removeAll">
Open all alerts
</button>
Replace clearAllNotifications
with removeAll
and the v-if
directive tests the condition of isNonEmpty
.
SvelteKit application
In the AlertBar
component,
import {
getClosedNotification,
removeNotification,
removeAllNotifications,
isNotEmpty
} from './stores/notification.svelte';
type Props = {
... omitted for brevity ...
style: string;
direction: string;
}
let {
... omitted for brevity ...
style = $bindable(),
direction = $bindable(),
}: Props = $props();
const closedNotifications = getClosedNotification();
Import the functions from ./stores/notification.svelte
. Then, delete the previously declaredclosedNotification
ref. Then, update the buttons with the function calls.
Delete closedNotifications
from the Props
type and $props()
.
Invoke getClosedNotification()
and assign the rune to closedNotifications
.
Then, update the buttons with the function calls.
{#each closedNotifications() as type (type)}
<button
class={getBtnClass(type) + ' mr-[0.5rem] btn'}
onclick={() => removeNotification(type)}
>
<OpenIcon />{ capitalize(type) }
</button>
{/each}
The #each
loop iterates closedNotifications()
because the result of the closedNotifications
rune is the reactive array.
{#if isNotEmpty()}
<button
class="btn btn-primary"
onclick={removeAllNotifications}>
Open all alerts
</button>
{/if}
Replace clearAllNotifications
with removeAllNotifications
and the if
control flow tests the condition of isNonEmpty
.
Angular 20 application
The AlertBarComponent
injects NotificationService
and accesses it's methods.
@Component({
selector: 'app-alert-bar',
imports: [FormsModule, OpenIconComponent, AlertDropdownComponent],
template: `...inline template...`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AlertBarComponent {
notificationService = inject(NotificationsService);
... omit other models ...
closedNotifications = this.notificationService.closedNotifications;
capitalize = capitalize;
remove(type: string) {
this.notificationService.remove(type);
}
removeAll() {
this.notificationService.removeAll();
}
isNonEmpty() {
return this.notificationService.isNonEmpty();
}
}
Rename the component methods and call the service methods in the method body.
closedNotification
is assigned the readonly signal in the service.
@for (type of closedNotifications(); track type) {
<button (click)="remove(type)">
<app-open-icon />{{ capitalize(type) }}
</button>
}
The click event invokes remove
after rename.
@if (isNonEmpty()) {
<button (click)="removeAll()">
Open all alerts
</button>
}
The @if
control flow tests the condition of isNonEmpty
. The click event invokes removeAll
after rename.
Apply the Notification Logic to the AlertList Component
Vue 3 application
In the AlertList
component,
import { useNotifications } from '@/composables/useNotification'
const { closedNotifications, add } = useNotifications()
const alerts = computed(() => props.alerts.filter((alert) =>
!closedNotifications.value.includes(alert.type))
)
Import the userNotifications
composable and destructure the functions.
The closedNotification
ref is replaced with the ref in the composable.
The alerts
computed ref uses the new closedNotification
ref to derive the currently opened notifications.
<Alert v-for="{ type, message } in alerts"
:key="type"
:type="type"
:alertConfig="alertConfig"
@closed="add">
{{ message }}
</Alert>
When the Alert
component emits the custom closed event, the add
function appends the type to the reactive array.
The template does not render the closed notification.
SvelteKit application
import { addNotification, getClosedNotification } from './stores/notification.svelte';
const closedNotifications = getClosedNotification();
let filteredNotifications = $derived.by(() =>
alerts.filter(alert => !closedNotifications().includes(alert.type))
);
The result of getClosedNotification
is assigned to closedNotifications
.
The filteredNotification
rune calls closedNotification()
to obtain the reactive array and perform filtering in the callback function. The final result is the currently opened notifications.
{#each filteredNotifications as alert (alert.type) }
<Alert {alert} {alertMessage} notifyClosed={() => addNotification(alert.type)} {alertConfig} />
{/each}
The notifyClosed
prop calls the addNotification
function to append type to the reactive array.
The template does not render the closed notification.
Angula 20 application
Inject NotificationService
in the AlertListComponent
.
@Component({
selector: 'app-alert-list',
imports: [AlertComponent, AlertBarComponent],
template: `...inline template...`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AlertListComponent {
notificationService = inject(NotificationsService);
... omitted for brevity ...
filteredAlerts = computed(() =>
this.alerts().filter(alert =>
!this.notificationService.closedNotifications().includes(alert.type))
);
add(type: string) {
this.notificationService.add(type);
}
}
The filteredAlerts
computed signal uses the service's closedNotifications
to derive currently opened notifications.
The add
method appends the type to the reactive array of the service.
@for (alert of filteredAlerts(); track alert.type) {
<app-alert [type]="alert.type"
[alertConfig]="alertConfig()"
(closeNotification)="add($event)">
{{ alert.message }}
</app-alert>
}
The custom closeNotification
event emits the type and the add
method appends it to the reactive array.
The template does not render the closed notification.
Github Repositories
- Vue 3: https://github.com/railsstudent/vue-alert-component/blob/main/src/components/AlertBar.vue
- Svelte 5: https://github.com/railsstudent/svelte-alert-component
- Angular 20: https://github.com/railsstudent/angular-alert-component
Github Pages
- Vue 3: https://railsstudent.github.io/vue-alert-component
- Svelte 5: https://railsstudent.github.io/svelte-alert-component
- Angular 20: https://railsstudent.github.io/angular-alert-component
Resources
- Vue Composable:https://vuejs.org/guide/reusability/composables
- Svelte 5 Store: https://svelte.dev/docs/svelte/stores
- Angular Service: https://angular.dev/guide/di/creating-injectable-service
Top comments (0)