Foto Andrea Mangano
Andrea Mangano

Sviluppo di un sistema di notifiche in React.js utilizzando hook e context API

Tutorial
28/05/2020
13m
Gestire la visualizzazione di messaggi di sistema può rivelarsi un compito complesso per molteplici ragioni. In questo tutorial mostro come creare un sistema di notifiche in React.js, privo di dipendenze da librerie esterne e sufficientemente completo da essere utilizzato in un applicativo reale.
Notifiche toast
React.js
Context API
Hook
TypeScript
Aiutami a diffonderlo

Le notifiche negli applicativi web

Le notifiche sono un pattern di design che ha la finalità di comunicare eventi all’utente (generalmente di sistema) tramite la comparsa di piccoli box con messaggi.

Le notifiche hanno nomi diversi in relazione al Design System che le adotta: variano da “snack bar” (Material Design), “toast” (Blueprint Design) fino a “banner” (Atlassian Design).

Indipendentemente da come appaiono (es. differente posizione nel layout) e si comportano (es. scomparsa dopo tot secondi piuttosto che alla chiusura da parte dell’utente), l’idea che le accomuna è la stessa: notificare un cambiamento nel sistema senza interromperne l’interazione utente all’interno dell’applicativo.

Uso comune, è la visualizzazione di messaggi di errori in caso di reperimento dati non riuscito o durante l’invio di un form; ma anche di messaggi che confermano l’esito positivo di una data azione.

Esse risolvono alcuni scenari in cui è complessa la gestione dei messaggi:

  • cambio automatico di pagina a seguito dell’immissione (con successo) di un form;
  • chiusura automatica di una modale prima che l’operazione al suo interno sia conclusa;
  • visualizzazione di messaggi di errori nel layout di componenti atomici (per esempio un bottone o una barra di azioni).

Scopo del tutorial

In questo tutorial mostro come creare un sistema di notifiche in React.js, privo di dipendenze da librerie esterne e sufficientemente completo da essere utilizzato in un applicativo reale.

Utilizzerò:

  • le context API per creare uno stato notifiche condiviso;
  • gli hook per accedere ad esso dai componenti che ne faranno uso.

Ciò presuppone che tu conosca queste nuove feature di React al fine di una totale comprensione del tutorial.

Qualora non ne fossi al corrente, ti consiglio di dare uno sguardo alla documentazione ufficiale.

Ecco un’anteprima del risultato finale:

sistema di notifiche

con relativo codice completo:

Sistema di notifiche (source code)

Nota: Per preferenza, il codice del tutorial è scritto in TypeScript, ma ciò non dovrebbe precluderti la comprensione se non sei solito farne uso (Leggi qui “Perchè usare TypeScript nella tua applicazione JavaScript”).

Requisiti

Vediamo subito le caratteristiche da sviluppare:

  • Stack di notifiche: le notifiche saranno impilabili, ovvero il sistema potrà gestire la visualizzazione di più notifiche contemporaneamente;
  • Criticità: Ogni notifica avrà uno specifico livello di “criticità”: solo scopo informativo, notifica di allerta e di errore;
  • Comparsa: Ogni nuova notifica, apparirà in cima allo stack;
  • Scomparsa: In relazione alla criticità, le notifiche scompariranno automaticamente dopo tot secondi (nessun livello di criticità o basso) e rimarranno a schermo fin quando l’utente non deciderà di chiuderle (alto livello di criticità) in modo da essere certi dell’avvenuta lettura;
  • Invio e lettura notifiche: ogni singolo componente potrà inviare al sistema una notifica ed eventualmente leggere quelle correntemente attive;
  • Cancellazione di notifiche: infine, dovrà essere possibile, rimuovere tutte le notifiche dallo stack o una specifica notifica.

Definizione delle notifiche

Definisco nel file types.ts l’oggetto notifica e le sue possibili tipologie:

// File types.ts

// Enumerativo per la tipologia notifica

export enum NotificationType {
  INFO = "Info", // Scopo informativo
  WARNING = "Warning", // Allerta
  ERROR = "Error", // Operazione con errore
  SUCCESS = "Success" // Operazione con successo
}

// Definizione notifica

export interface Notification {
  id: number;
  type: NotificationType;
  message: string;
  ttl?: number;
}

L’attributo ttl (time to leave) rappresenta il tempo di permanenza a schermo della notifica espresso in millisecondi.

Una notifica che deve di rimanere fissa a schermo avrà tempo infinito, mentre una notifica transitoria scomparirà dopo un tempo determinato.

Creo pertanto due costanti a supporto:

// File constants.ts

export const NO_TTL = Infinity;

export const DEFAULT_TTL = 7000; // Equivalente a 7 secondi

Azioni di aggiornamento dello stack notifiche

Le azioni hanno lo scopo di permettere ai componenti dell’applicazione di aggiungere e rimuovere notifiche dallo stack.

Definisco le tipologie di azioni possibili:

// File types.ts

export enum NotificationActionType {
  ADD = 'Add', // Azione di aggiunta notifica
  REMOVE = 'Remove', // Azione di rimozione singola notifica
  REMOVE_ALL = 'Remove all', // Azione di pulitura dello stack (rimozione di tutte le notifiche presenti)
}

Un’azione è caratterizzata dal tipo e dall’eventuale payload (un oggetto notifica):

// File types.ts

export interface NotificationsAction {
  type: NotificationActionType,
  notification?: Notification;
}

Ovvio che, una notifica di tipo REMOVE_ALL, non porta con sè nessun payload.

In base alla tipologia dell’azione eseguita, la lista di notifiche, sarà modificata di conseguenza:

  • L’azione di aggiunta impila la notifica al primo posto nello stack;
  • L’azione di rimozione elimina la notifica dal listato;
  • Infine, l’azione di rimozione dell’intero stack restituisce un array vuoto.

Scrivo una funzione reducer in stile Redux che, a partire da un’array di notifiche, ritorna un nuovo array di notifiche aggiornato, in accordo con l’azione su di esso eseguita:

// File notificationsReducer.ts

export function notificationsReducer(
  state: Notification[],
  action: NotificationAction
): Notification[] {
  switch (action.type) {
    case NotificationActionType.ADD:
      return [action.notification!, ...state];
    case NotificationActionType.REMOVE:
      return state.filter(
        notification => notification.id !== action.notification!.id
      );
    case NotificationActionType.REMOVE_ALL:
      return [];
    default:
      throw new Error("Action not implemented yet.");
  }
}

Ma dove utilizzo questa funzione? Tra un attimo lo scropriremo.

Creazione del contesto

Il sistema di notifiche avrà uno stato che comprende:

  • l’array di notifiche attuale
  • il set di azioni (precedentemente definito) che agiranno su di esso.

Chiamiamo tutto questo con il nome “contesto del sistema di notifiche”.

Attraverso il contesto, i componenti potranno accedere al listato notifiche e alle singole funzionalità di aggiunta ed eliminazione.

Definisco il valore del contesto:

// File types.ts

interface NotificationContextValue {
  notifications: Notification[];
  send(message: string, type: NotificationType, ttl?: number): void;
  remove(notification: Notification): void;
  removeAll: VoidFunction;
}

Gestione dell’array di notifiche

A differenza delle azioni, l’array di notifiche è soggetto a mutazioni. Esse sono la conseguenza delle operazioni di aggiunta e rimozione su di esso eseguite.

Per gestire i cambiamenti utilizzo l’hook React useReducer. Se hai familiarità con Redux sai già come funziona.

useReducer accetta:

  • una funzione reducer per la modifica dell’oggetto di stato
  • il valore iniziale dello stato

e restituisce:

  • il valore corrente dello stato
  • la funzione di dispatch per eseguire azioni su di esso.
const [state, dispatch] = useReducer(reducer, initialState);

Nel mio caso ho:

const [notifications, dispatch] = useReducer(notificationsReducer, []);

dove notificationsReducer è la funzione che ho scritto precedentemente per modificare l’array di notifiche.

In questo modo posso facilmente operare sull’array delle notifiche e recuperare il suo stato attuale.

Azione di invio notifica

Creo la funzione di invio notifiche usando dispatch fornita da useReducer. Essa spedisce un’azione di tipo ADD che ha come payload la notifica creata a partire dai parametri in input:

function send(
  message: string,
  type: NotificationType = NotificationType.INFO,
  ttl: number = DEFAULT_TTL
) {
  // Creo l'oggetto notifica (uso la data corrente come id)
  const notification = {
    id: Date.now(),
    message,
    type,
    ttl,
  };

  // Spedisco l'azione tramite la funzionalità di dispatch
  dispatch({
    type: NotificationActionType.ADD,
    notification,
  });
};

Al fine di gestire il tempo di permanenza nello stack di notifiche, completo la funzione come segue:

function send(
  message: string,
  type: NotificationType = NotificationType.INFO,
  ttl: number = DEFAULT_TTL
) {
  // const notification = {...};
  // dispatch({...});

  if (ttl > 0 && Number.isFinite(ttl)) {
    setTimeout(() => {
      dispatch({
        type: NotificationActionType.REMOVE,
        notification,
      });
    }, ttl);
  }
};

Nella nuova implementazione verifico il parametro ttl.

Se ha un valore finito maggiore di 0 la notifica non è permanente (diversamente il ttl è infinito). Attendo lo scadere del tempo definito da ttl e rimuovo la notifica dello stack lanciando un nuovo dispatch con azione di REMOVE.

Azioni di rimozione notifiche

Le rimanenti funzioni di rimozione seguono la medesima idea:

function remove(notification) {
  dispatch({
    type: NotificationActionType.REMOVE,
    notification,
  });
}

function removeAll() {
  dispatch({
    type: NotificationActionType.REMOVE_ALL,
  });
}

Creazione di un custom hook per generare il contesto

A questo punto, posso creare un’unica funzione (nella forma di un custom hook) che generi e restituisca il valore del contesto (manca poco per vederla in azione!):

export default function useNotificationsReducer(): NotificationContextValue {
  const [notifications, dispatch] = useReducer(notificationsReducer, []);

  return {
    notifications,
    send: ...,
    remove: ...,
    removeAll: ...
  };
}

di cui send, remove e removeAll sono le funzioni sopra definite.

Ecco come appare l’intero contenuto del file useNotificationContextValue.ts:

// File useNotificationContextValue.ts

function send(dispatch: Dispatch<NotificationAction>) {
  return (
    message: string,
    type: NotificationType = NotificationType.INFO,
    ttl: number = DEFAULT_TTL
  ) => {
    const notification = {
      id: Date.now(),
      message,
      type,
      ttl
    };

    dispatch({
      type: NotificationActionType.ADD,
      notification
    });

    if (ttl > 0 && Number.isFinite(ttl)) {
      setTimeout(() => {
        dispatch({
          type: NotificationActionType.REMOVE,
          notification: notification
        });
      }, ttl);
    }
  };
}

function remove(dispatch: Dispatch<NotificationAction>) {
  return (notification: Notification) =>
    dispatch({
      type: NotificationActionType.REMOVE,
      notification
    });
}

function removeAll(dispatch: Dispatch<NotificationAction>) {
  return () =>
    dispatch({
      type: NotificationActionType.REMOVE_ALL
    });
}

// Hook che restituisce il valore di un contesto notifiche
export default function useNotificationContextValue(): NotificationContextValue {
  const [notifications, dispatch] = useReducer(notificationsReducer, []);

  return {
    notifications,
    send: send(dispatch),
    remove: remove(dispatch),
    removeAll: removeAll(dispatch)
  };
}

Per garantire una maggiore leggibilità, le azioni sono definite al di fuori dell’hook come currying function in modo da iniettare la funzione dispatch nel loro scope.

Provider di notifiche

I componenti dell’applicativo, dovranno poter accedere allo stack di notifiche e alle funzionalità di invio e rimozione, a prescindere dal loro livello di annidamento all’interno dell’applicazione.

La casistica in cui mi trovo si presta per l’uso delle contextAPI di React.

Creo un context tramite la funzione createContext e passo ad esso dei valori di default:

// File NotificationContext.tsx

const noop = () => {};

const DEFAULT_CONTEXT_VALUE = {
  notifications: [],
  send: noop,
  remove: noop,
  removeAll: noop
};

// Creo il context con dei valori di default
export const NotificationsContext = React.createContext<
  NotificationContextValue
>(DEFAULT_CONTEXT_VALUE);

Da notare che ho già definito l’oggetto di contesto precedentemente

interface NotificationContextValue {
  notifications: Notification[];
  send(message: string, type: NotificationType, ttl?: number): void;
  remove(notification: Notification): void;
  removeAll: VoidFunction;
}

ed implementato la funzione useNotificationContextValue che ne genera il valore.

Il contesto fornisce un modo per far passare i dati attraverso l’albero dei componenti senza dover passarli manualmente ad ogni livello.

Il contesto rappresenta uno stato “globale” per un sotto albero di componenti React.

Ogni oggetto Context viene fornito con un componente Provider che consente ai componenti Consumer di sottoscrivere le modifiche al valore del contesto:

const { Provider, Consumer } = createContext(defaultValue);

È necessario inserire il Provider tra i nodi padri per rendere il contesto disponibile all’intero applicativo: solo i componenti nell’alberatura del Provider potranno accedervi.

Creo l’index dell’applicazione e inserisco il componente Provider come nodo root:

// File index.tsx

const App: FunctionComponent = () => {
  // Genera il valore del contesto
  const contextValue = useNotificationContextValue();
  // Prende i componenti Provider e Consumer dal contesto
  const { Provider, Consumer } = NotificationsContext;

  // Il valore del contesto viene passato al Provider che lo rende disponibile a tutti i componenti figli
  return (
    <Provider value={contextValue}>
      {/* n nodi figli possono accedere al valore del contesto usando NotificationContext.Consumer */}
      <NotificationContext.Consumer>
        { contextValue => /* Il componente n accede al contesto */ }
      </NotificationContext.Consumer>
    </Provider>
  );
};

const rootElement = document.getElementById("root");
render(<App />, rootElement);

Nota: NotificationContext.Consumer non è l’unico modo di accedere al contesto da un componente figlio. Nel corso del tutorial mostro come accedere al contesto tramite l’hook React useContest() dai componenti Consumer.

Componenti Consumer

Eccoci giunti al punto in cui il sistema di notifiche viene usato dai componenti dell’applicativo.

Creo due componenti che ricalcano i casi d’uso comuni:

  • ViewNotifications: componente che visualizza tutte le notifiche
  • SendNotifications: componente di test che invia le notifiche

Render delle notifiche

Il componente ViewNotifications si occupa di:

  • leggere e renderizzare il listato delle notifiche;
  • fornire ogni notifica di un pulsante di chiusura.

Una versione trivial del componente appare così:

// File ViewNotifications.tsx

const ViewNotifications: FunctionComponent = () => {
  /* Accede al contesto per recuperare l'array delle notifiche
  e la funzione di rimozione */
  const { notifications, remove } = useContext(NotificationsContext);
  return (
    <div>
      {notifications.map(notification => (
        <div key={notification.id}>
          {notification.message}
          <button onClick={() => remove(notification)}>Chiudi</button>
        </div>
      ))}
    </div>
  );
};

Nota che l’utilizzo dell’hook react useContext permette di accedere al valore del contesto sensa usare il componente Consumer.

Dal contesto accedo all’array di notifiche per renderizzarne i valori e alla funzione di rimozione notifica.

Colloco infine il componente nel file index.tsx come figlio del Provider:

// File index.tsx

...
  return (
    <Provider value={contextValue}>
      <ViewNotifications />
    </Provider>
  );
...

Componente test per invio notifiche

Creo ora un componente che ha pura finalità di provare le funzionalità implementate.

Al suo interno inserisco tre bottoni che si occupano di inviare notifiche di info e di errore e di rimuovere tutte le notifiche nello stack.

// File SendNotifications.tsx

const SendNotifications: FunctionComponent = () => {
  // Accedo al contesto per recuperare le funzioni
  const { send, removeAll } = useContext(NotificationsContext);
  return (
    <div>
      <button
        onClick={() =>
          send(`Info message ${Date.now()}`, NotificationType.INFO)
        }
      >
        Send info notification
      </button>
      <button
        onClick={() =>
          send(`Error message ${Date.now()}`, NotificationType.ERROR, NO_TTL)
        }
      >
        Send error notification
      </button>
      <button onClick={() => removeAll()}>Remove all notifications</button>
    </div>
  );
};

Colloco infine, anche questo componente nell’alberatura del Provider:

// File index.tsx

...
  return (
    <Provider value={contextValue}>
      <SendNotifications />
      <ViewNotifications />
    </Provider>
  );
...

Il gioco è fatto. Il sistema di notifiche, anche se nella sua implementazione base, è totalmente funzionante:

Sistema di notifiche (source code)

Miglioramenti: idee

Ci sono alcuni semplici miglioramenti funzionali che si possono apportare al codice finora scritto.

È possibile infatti creare delle specifiche azioni “preconfezionate” per l’invio di messaggi di un particolare tipo:

function sendInfo(dispatch: Dispatch<NotificationAction>) {
  return (message: string) => {
    send(dispatch)(message, NotificationType.INFO, DEFAULT_TTL);
  };
}

function sendSuccess(dispatch: Dispatch<NotificationAction>) {
  return (message: string) => {
    send(dispatch)(message, NotificationType.SUCCESS, DEFAULT_TTL);
  };
}

function sendWarning(dispatch: Dispatch<NotificationAction>) {
  return (message: string) => {
    send(dispatch)(message, NotificationType.WARNING, DEFAULT_TTL);
  };
}

function sendError(dispatch: Dispatch<NotificationAction>) {
  return (message: string) => {
    send(dispatch)(message, NotificationType.ERROR, NO_TTL);
  };
}

I vantaggi sono di due tipi:

  • Maggiore comodità di utilizzo: basta scegliere l’azione di invio relativa al tipo e passare il testo della notifica;
  • Coerenza nel comportamento: tutti i messaggi di un tipo seguiranno lo stesso comportamento di scomparsa (ad esempio, tutti messaggi di errore avranno permanenza fissa a schermo).

Ci sono altri miglioramenti interessanti che ti suggerisco qualora volessi estendere il prototipo finora creato:

  • Azioni utente correlate: immagina un pulsante “riprova” posto in una notifica di errore per riprovare un’operazione non riuscita;
  • Calcolo della permanenza a schermo: si potrebbe implementare una funzione che calcoli il ttl in relazione alla lunghezza del messaggio o ad altre variabili di sistema;
  • Limite stack di notifiche: quando le notifiche sono troppe potrebbero ricoprire gran parte dello schermo (sopratutto su un dispositivo mobile) ostacolando l’uso dell’applicativo all’utente. Si può quindi ipotizzare di limitare il numero di notifiche visualizzate contemporaneamente nello stack tramite una sorta di “coda in entrata”.

Conclusioni

L’idea del tutorial nasce da una necessità pratica nata durante la realizzazione di un prodotto commerciale.

L’esperienza dei trascorsi mesi di sviluppo, ha rivelato al mio team molteplici criticità nel gestire la visualizzazione di messaggi all’interno dei singoli componenti.

Oltre che risolvere i problemi inerenti alla UX (e garantire un’esperienza utente più uniforme), l’integrazione del sistema di notifiche ha portato diversi vantaggi in termini di:

  • Design: si evita di sprecare tempo nel progettare, per ogni singolo componente, l’integrazione dei feedback all’utente;
  • Sviluppo: come appena visto, basta davvero poco per mostrare dei messaggi senza preoccuparsi del posizionamento nel layout e del componente con quale renderizzarli;
  • Test: i test d’interfaccia divengono più semplici. Per la maggiore dei casi, si tratterà di verificare che le chiamate alle singole azioni avvengano nel contesto desiderato;
  • Refactoring: in caso di refactoring visuale, non occorre accertarsi che i messaggi si renderizzino correttamente all’interno di ogni singolo componente.

Nel seguente link puoi trovare la versione completa, comprensiva di stili e di alcune migliorie implementative:

Sistema di notifiche (source code)

Alla prossima!

Notifiche toast
React.js
Context API
Hook
TypeScript