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:
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ò:
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:
con relativo codice completo:
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”).
Vediamo subito le caratteristiche da sviluppare:
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
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:
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.
Il sistema di notifiche avrà uno stato che comprende:
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;
}
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:
e restituisce:
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.
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
.
Le rimanenti funzioni di rimozione seguono la medesima idea:
function remove(notification) {
dispatch({
type: NotificationActionType.REMOVE,
notification,
});
}
function removeAll() {
dispatch({
type: NotificationActionType.REMOVE_ALL,
});
}
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.
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.
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:
Il componente ViewNotifications si occupa di:
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>
);
...
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:
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:
Ci sono altri miglioramenti interessanti che ti suggerisco qualora volessi estendere il prototipo finora creato:
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:
Nel seguente link puoi trovare la versione completa, comprensiva di stili e di alcune migliorie implementative:
Alla prossima!