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
AlertListcomponent. It can be extracted to aAlertDropdowncomponent. - The
AlertListandAlertBarcomponents have logic to manage the state of theclosedNotificationsref. 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 to shareable data for both AlertList and AlertBar to access.
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.
Angular 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 service's reactive array.
@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)