DEV Community

Connie Leung
Connie Leung

Posted on

Day 26 - Alert Component Part 5 - Extract logic and component from Alert Bar

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 a AlertDropdown component.
  • The AlertList and AlertBar components have logic to manage the state of the closedNotifications 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>
Enter fullscreen mode Exit fullscreen mode
<template>
    <span>{{ label }}&nbsp;&nbsp;</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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
<span>{ label }&nbsp;&nbsp;</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>
Enter fullscreen mode Exit fullscreen mode

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() }}&nbsp;&nbsp;</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>();
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 ]));
    }
}
Enter fullscreen mode Exit fullscreen mode

#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()
Enter fullscreen mode Exit fullscreen mode

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>    
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

The click event invokes remove after rename.

@if (isNonEmpty()) { 
    <button (click)="removeAll()">
      Open all alerts
    </button>
}
Enter fullscreen mode Exit fullscreen mode

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))
)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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))
);
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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

Github Pages

Resources

Top comments (0)