Streamlining JavaScript Components: A Deep Dive into Refactoring and State Management in Vue, Svelte, and Angular

Modern web development hinges on building maintainable and scalable applications. A crucial aspect of this is designing clean, reusable components and managing application state efficiently. In this article, we explore how to refactor existing components and centralize state management across three popular JavaScript frameworks: Vue 3, SvelteKit (Svelte 5), and Angular 20. We’ll specifically look at improving an AlertBar component and abstracting notification logic to enhance code clarity and reduce complexity.

Our journey begins by addressing two key areas for improvement:

  1. Component Extraction: Identifying and extracting a reusable AlertDropdown component from the existing AlertBar.
  2. State Management: Encapsulating the logic and state for closedNotifications into a dedicated state management solution for each framework.

Let’s dive into the specifics!

Enhancing UI Reusability with the AlertDropdown Component

The initial AlertBar component contains a static label and a select element for managing alerts, leading to duplicated code if similar dropdowns are needed elsewhere. To combat this, we’ll create a new, generic AlertDropdown component.

Vue 3 Application

In Vue 3, the AlertDropdown component leverages defineProps for input properties like label and items, and defineModel for two-way binding of the selectedValue. This allows the parent component to easily control and react to changes in the dropdown’s selected value.

<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 }}&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>

The component defines a Props type with label and items, and uses defineModel for a two-way bound selectedValue ref.

SvelteKit (Svelte 5) Application

Svelte 5 simplifies property binding with runes. The AlertDropdown component in SvelteKit uses $props() to define its input properties and $bindable() to establish two-way binding for the selectedValue.

<script lang="ts">
    type Props = {
        label: string;
        items: { text: string, value: string }[];
        selectedValue: string;
    }

    let { label, items, selectedValue = $bindable() }: Props = $props();
</script>
<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>

Here, $bindable() on selectedValue enables direct two-way data flow with the parent.

Angular 20 Application

Angular 20 introduces input and model signals for component inputs and two-way bindings. The AlertDropdownComponent utilizes input.required for its label and items, and model.required for the selectedValue, making bindings explicit and type-safe.

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>();
}

Refactoring the AlertBar Component

With the AlertDropdown component in place, the AlertBar component becomes significantly cleaner, reducing repetitive HTML and improving readability.

Vue 3 Application

The Vue 3 AlertBar now uses two instances of AlertDropdown, binding its label and items props and using v-model:selectedValue for two-way binding the style and direction refs.

<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>
SvelteKit Application

Similarly, the SvelteKit AlertBar component integrates AlertDropdown using direct prop binding and bind:selectedValue for two-way data flow.

<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>
Angular 20 Application

The Angular 20 AlertBarComponent utilizes the app-alert-dropdown selector, passing inputs via [label] and [items] and binding the selectedValue using [(selectedValue)].

<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>

Centralizing Notification State with Dedicated Solutions

The AlertList and AlertBar components previously managed their closedNotifications state independently, leading to scattered logic for adding, removing, clearing, and retrieving notifications. We’ll now extract this logic into a centralized state management solution for each framework.

Vue 3 Application: The Composable Approach (`useNotifications`)

Vue 3’s composables are perfect for encapsulating reusable stateful logic. We create a useNotifications composable that manages a shared closedNotifications ref and provides functions for its manipulation. The closedNotifications ref is exposed as readonly to prevent direct mutations from components.

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,
        removeAll, // Renamed from clearAll in example
        isNonEmpty,
        add
    }
}

SvelteKit (Svelte 5) Application: Leveraging Runes for State

Svelte 5 introduces runes, offering a powerful way to manage reactive state. Our notification logic is centralized in a store-like module using $state for mutable state and $derived for read-only derived state.

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);
}

The closedNotifications rune is a derived value, providing a reactive, read-only view of the internal state.

Angular 20 Application: Service-Based State Management (`NotificationsService`)

In Angular, services are the idiomatic way to encapsulate business logic and share state. The NotificationsService uses a private signal for mutable state and exposes a asReadonly() version of the signal, ensuring immutability for consumers.

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 ]));
    }
}

Integrating Notification Logic into AlertBar and AlertList Components

With the centralized notification logic, both AlertBar and AlertList components can now consume these services or composables, simplifying their internal logic and ensuring consistent state management.

Vue 3 Application

In Vue 3, useNotifications is imported into both AlertBar and AlertList.
The AlertBar component destructures closedNotifications, removeAll, isNonEmpty, and remove from the composable. It updates button click handlers and v-if directives to use these new functions.

import { useNotifications } from '@/composables/useNotification'
const { closedNotifications, removeAll, isNonEmpty, remove } = useNotifications()
<button v-for="type in closedNotifications"
    :key="type"
    @click="remove(type)"
>
    <OpenIcon />{{ capitalize(type) }}
</button>    
<button
    v-if="isNonEmpty()"
    class="btn btn-primary" 
    @click="removeAll">
    Open all alerts
</button>

The AlertList component also uses useNotifications, replacing its internal closedNotification ref. The alerts computed property now filters based on the closedNotifications from the composable, and the Alert component’s closed event calls the add function from the composable.

import { useNotifications } from '@/composables/useNotification'
const { closedNotifications, add } = useNotifications()
const alerts = computed(() => props.alerts.filter((alert) => 
  !closedNotifications.value.includes(alert.type))
)
<Alert v-for="{ type, message } in alerts"
    :key="type"
    :type="type"
    :alertConfig="alertConfig"
    @closed="add">
    {{  message }}
</Alert>

SvelteKit Application

For SvelteKit, the exported functions from the notification store are imported.
In AlertBar, the getClosedNotification() rune is assigned, and button handlers are updated to call removeNotification and removeAllNotifications. The {#each} block iterates closedNotifications() to get the reactive array.

import { 
    getClosedNotification, 
    removeNotification, 
    removeAllNotifications, 
    isNotEmpty 
} from './stores/notification.svelte';

// ... omitted props definition ...

const closedNotifications = getClosedNotification();
{#each closedNotifications() as type (type)}
    <button
        class={getBtnClass(type) + ' mr-[0.5rem] btn'}
        onclick={() => removeNotification(type)}
    >
        <OpenIcon />{ capitalize(type) }
    </button>    
{/each}
{#if isNotEmpty()}
    <button
        class="btn btn-primary" 
        onclick={removeAllNotifications}>
        Open all alerts
    </button>
{/if}

In AlertList, getClosedNotification and addNotification are imported. A $derived.by rune is used to filter alerts based on the closedNotifications() rune. The Alert component’s notifyClosed prop calls addNotification.

import { addNotification, getClosedNotification } from './stores/notification.svelte';

const closedNotifications  = getClosedNotification();
let filteredNotifications = $derived.by(() => 
    alerts.filter(alert => !closedNotifications().includes(alert.type))
);
{#each filteredNotifications as alert (alert.type) } 
    <Alert {alert} {alertMessage} notifyClosed={() => addNotification(alert.type)} {alertConfig} />
{/each}

Angular 20 Application

In Angular, the NotificationsService is injected into both AlertBarComponent and AlertListComponent.
AlertBarComponent uses the injected service’s methods for remove, removeAll, and isNonEmpty, and assigns the service’s closedNotifications readonly signal to a local property.

import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms'; // Ensure FormsModule is imported if using ngModel
import { OpenIconComponent } from '../open-icon/open-icon.component'; // Assuming OpenIconComponent path
import { AlertDropdownComponent } from '../alert-dropdown/alert-dropdown.component'; // Assuming AlertDropdownComponent path
import { NotificationsService } from '../../services/notifications.service'; // Adjust path as needed
import { capitalize } from '../../utils/capitalize'; // Assuming capitalize utility

@Component({
  selector: 'app-alert-bar',
  imports: [FormsModule, OpenIconComponent, AlertDropdownComponent],
  template: `
    <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>
    <div>
        @for (type of closedNotifications(); track type) {
            <button (click)="remove(type)">
                 <app-open-icon />{{ capitalize(type) }}
            </button>
        }
        @if (isNonEmpty()) { 
            <button (click)="removeAll()">
              Open all alerts
            </button>
        }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AlertBarComponent {
  notificationService = inject(NotificationsService);

  // ... omit other models like hasCloseButton, style, direction, c (config) ...

  closedNotifications = this.notificationService.closedNotifications;

  capitalize = capitalize; // Assuming capitalize utility

  remove(type: string) {
    this.notificationService.remove(type);
  }

  removeAll() {
    this.notificationService.removeAll();
  }

  isNonEmpty() {
    return this.notificationService.isNonEmpty();
  }
}
@for (type of closedNotifications(); track type) {
    <button (click)="remove(type)">
         <app-open-icon />{{ capitalize(type) }}
    </button>
}
@if (isNonEmpty()) { 
    <button (click)="removeAll()">
      Open all alerts
    </button>
}

AlertListComponent injects NotificationsService. Its filteredAlerts computed signal now depends on the service’s closedNotifications signal. The add method delegates to the service’s add method.

import { Component, computed, inject, input, ChangeDetectionStrategy } from '@angular/core';
import { AlertComponent } from '../alert/alert.component';
import { AlertBarComponent } from '../alert-bar/alert-bar.component';
import { NotificationsService } from '../../services/notifications.service'; // Adjust path as needed

@Component({
  selector: 'app-alert-list',
  imports: [AlertComponent, AlertBarComponent],
  template: `
    <app-alert-bar 
      [hasCloseButton]="hasCloseButton()" 
      [(style)]="style" 
      [(direction)]="direction" 
      [config]="alertConfig()">
    </app-alert-bar>

    @for (alert of filteredAlerts(); track alert.type) {
      <app-alert [type]="alert.type" 
        [alertConfig]="alertConfig()"
        (closeNotification)="add($event)">
        {{ alert.message }}
      </app-alert>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AlertListComponent {
  notificationService = inject(NotificationsService);

  alerts = input.required<{ type: string, message: string }[]>();
  alertConfig = input.required<any>(); // Define a more specific type for alertConfig if possible

  // ... omitted hasCloseButton, style, direction models ...

  filteredAlerts = computed(() => 
    this.alerts().filter(alert => 
      !this.notificationService.closedNotifications().includes(alert.type))
  ); 

  add(type: string) {
    this.notificationService.add(type);
  }
}
@for (alert of filteredAlerts(); track alert.type) {
  <app-alert [type]="alert.type" 
    [alertConfig]="alertConfig()"
    (closeNotification)="add($event)">
    {{ alert.message }}
  </app-alert>
}

Conclusion

By extracting the AlertDropdown component and centralizing the closedNotifications logic into framework-specific state management solutions (Vue Composables, Svelte 5 Runes, Angular Services), we achieve significant improvements in code organization, reusability, and maintainability. This structured approach not only cleans up our component templates and logic but also provides a clear pattern for handling shared state across an application.

Github Repositories

Github Pages

Resources

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.
You need to agree with the terms to proceed