Cos'è Zustand e come si usa
Cos'è Zustand, come si crea uno store e perché i selettori sono il punto centrale, con il confronto onesto con Context e Redux e gli errori più comuni.
Hai un valore che serve in punti lontani dell'applicazione, lo hai messo in Context, e ora ogni volta che cambia si rirenderizza mezza pagina. Oppure guardi Redux e ti chiedi se serve davvero tutta quella struttura per un carrello. Zustand nasce esattamente in quello spazio in mezzo. In questo articolo trovi come si crea uno store, perché il selettore è la cosa che devi capire prima di tutte le altre, e quando Zustand non è lo strumento giusto.
Cos'è
Zustand è una piccola libreria per tenere stato client condiviso fuori dai componenti React, che si legge con una funzione selettore e si aggiorna chiamando azioni, senza provider obbligatori e senza boilerplate.
"Piccola" non è un aggettivo di cortesia: l'intera API che userai nel 99% dei casi sta in due funzioni. È una libreria nel senso pieno del termine — la chiami tu, dove vuoi, e non ti impone nessuna struttura di progetto.
Prima di tutto: ti serve?
Metto questo in cima perché è la parte che di solito manca.
Zustand risolve lo stato client globale: valori che vivono nel browser, che non stanno in un database e che servono in parti dell'app che non condividono un genitore vicino. Un carrello prima del checkout, lo stato di un editor, una barra laterale con filtri di visualizzazione, una coda di notifiche.
Non ti serve se:
- lo stato è usato da un componente solo, o da lui e da due figli — lì basta
useState; - il valore cambia raramente (tema, lingua, utente autenticato) — Context va benissimo e non aggiungi dipendenze;
- i dati vengono da una API — e questo è il caso più frequente in assoluto, ci torno alla fine.
Il quadro completo, con i tipi di stato e la scala di soluzioni, è in gestione dello stato in React.
Creare uno store
Uno store è una funzione che ricevi da create. Dentro metti i valori e le funzioni che li modificano.
// store/carrello.js
import { create } from "zustand";
export const useCarrello = create((set) => ({
articoli: [],
aggiungi: (prodotto) =>
set((stato) => ({ articoli: [...stato.articoli, prodotto] })),
rimuovi: (id) =>
set((stato) => ({
articoli: stato.articoli.filter((a) => a.id !== id),
})),
svuota: () => set({ articoli: [] }),
}));
Tre cose da notare subito.
Non c'è nessun provider. Non devi avvolgere l'applicazione: lo store esiste come modulo, e chi lo importa lo vede. È la ragione principale per cui Zustand sembra più leggero di tutto il resto.
set fa un merge superficiale. Restituisci solo i campi che cambiano, non l'oggetto intero. Attenzione però: il merge è di primo livello, quindi su oggetti annidati devi ricostruire tu il ramo.
Le azioni stanno dentro lo store, accanto ai dati. Non sono reducer, non sono action type: sono normali funzioni che chiamano set. Chi legge il file capisce in trenta secondi tutti i modi in cui quello stato può cambiare, ed è un valore che si sottovaluta.
Quando il nuovo valore dipende da quello vecchio usa la forma con la funzione, come sopra. Se non dipende, la forma diretta va bene:
svuota: () => set({ articoli: [] });
Il selettore: la parte che devi capire
Qui sta il cuore, e qui si fa l'errore numero uno.
Lo hook che hai creato si può chiamare in due modi:
// prendi tutto lo store
const store = useCarrello();
// prendi una fetta sola, con un selettore
const articoli = useCarrello((stato) => stato.articoli);
Nel primo caso il componente si rirenderizza a ogni singolo cambiamento dello store, anche di campi che non usa. Nel secondo si rirenderizza solo quando quella fetta cambia davvero.
È esattamente il problema che Zustand esiste per risolvere: se prendi tutto, hai riprodotto il comportamento di Context e portato in casa una dipendenza per niente.
function BadgeCarrello() {
// solo il numero: questo componente non si muove se cambiano altri campi
const quantita = useCarrello((stato) => stato.articoli.length);
return <span>{quantita}</span>;
}
function BottoneAggiungi({ prodotto }) {
// le azioni sono stabili: prenderle così non causa rirender
const aggiungi = useCarrello((stato) => stato.aggiungi);
return <button onClick={() => aggiungi(prodotto)}>Aggiungi</button>;
}
Nota BottoneAggiungi: prende solo l'azione. Non legge articoli, quindi non si rirenderizza mai quando il carrello cambia. Con Context questa separazione non è possibile senza dividere il context in due.
La trappola dei selettori che creano oggetti
Il confronto tra il valore vecchio e quello nuovo è per identità. Un selettore che costruisce un oggetto o un array nuovo restituisce ogni volta un riferimento diverso, quindi il componente si rirenderizza sempre:
// no: oggetto nuovo a ogni render, il confronto fallisce sempre
const { articoli, totale } = useCarrello((s) => ({
articoli: s.articoli,
totale: s.totale,
}));
// sì: due letture separate, ognuna con il suo confronto
const articoli = useCarrello((s) => s.articoli);
const totale = useCarrello((s) => s.totale);
Se ti serve davvero comporre più campi in un colpo solo, Zustand offre useShallow, che confronta campo per campo invece che per identità:
import { useShallow } from "zustand/shallow";
const { articoli, totale } = useCarrello(
useShallow((s) => ({ articoli: s.articoli, totale: s.totale })),
);
Ed ecco il solito principio: i valori derivati non si mettono nello store, si calcolano.
// nello store tieni solo la verità
articoli: [],
// nel componente il resto è un calcolo
const totale = useCarrello((s) =>
s.articoli.reduce((somma, a) => somma + a.prezzo, 0),
);
Leggere fuori dai componenti
Serve più spesso di quanto sembri: dentro una funzione di utilità, in un gestore di evento del browser, in un test.
// leggere il valore corrente senza hook
const articoli = useCarrello.getState().articoli;
// scrivere da qualsiasi punto
useCarrello.getState().svuota();
Non è una scorciatoia da usare dentro i componenti — lì il valore non si aggiornerebbe — ma fuori è comodissimo, e con Context semplicemente non è possibile.
Confronto onesto
| Context | Zustand | Redux Toolkit | |
|---|---|---|---|
| Provider necessario | Sì | No | Sì |
| Rirender selettivo | No, tutti i consumatori | Sì, tramite selettore | Sì, tramite selettore |
| Codice di contorno | Poco | Pochissimo | Moderato |
| Strumenti di debug | Nessuno | Estensione opzionale | Molto buoni, time travel |
| Struttura imposta | Nessuna | Nessuna | Slice, convenzioni chiare |
| Adatto a | Valori stabili e rari | Stato client globale che cambia | Team grandi, logica complessa |
Rispetto a Context, Zustand risolve un problema preciso: i rirender indiscriminati. Se il tuo valore cambia una volta all'ora, Context va benissimo e non hai motivo di cambiare. Se cambia di continuo ed è letto in venti posti, Zustand ti dà il controllo granulare.
Rispetto a Redux, la differenza non è che Redux sia superato. Redux Toolkit ha strumenti di sviluppo ottimi — vedi ogni azione, il diff dello stato, puoi tornare indietro nel tempo — e una struttura che su un team di dieci persone vale più dell'eleganza: tutti scrivono nello stesso modo. Zustand non ti impone nulla, e su un team grande "nessuna struttura imposta" significa cinque store scritti con cinque filosofie diverse.
La sintesi pratica: da solo o in team piccolo, Zustand. Team grande con logica di dominio complessa e bisogno di tracciare ogni transizione, Redux Toolkit merita ancora di essere considerato.
Gli errori che fanno perdere ore
Leggere tutto lo store. Ripeto perché è davvero il primo: useCarrello() senza selettore annulla il vantaggio della libreria. È l'errore che si trova in metà dei progetti che usano Zustand.
Selettori che creano oggetti nuovi. Rirender continui che sembrano inspiegabili, finché non guardi il selettore. Usa useShallow o letture separate.
Valori derivati salvati nello store. totale accanto ad articoli significa due fonti di verità e un'incoerenza certa quando dimenticherai di aggiornarne una.
Uno store unico con tutto dentro. Zustand non ti obbliga a un singolo store globale, e non c'è nessun vantaggio a farlo. Uno store per dominio — carrello, notifiche, editor — è più leggibile e si cancella senza paura.
Dimenticare l'idratazione con il rendering lato server. Se usi Next.js e lo store viene popolato da localStorage, il primo render sul server e il primo nel browser non coincidono e ottieni un errore di idratazione. La soluzione è leggere il valore persistito dopo il montaggio, non durante il primo render.
Mutare lo stato invece di sostituirlo. stato.articoli.push(x) non provoca nessun rirender, perché il riferimento all'array non cambia. Costruisci un array nuovo.
L'onestà che conta: i dati del server
Se stai mettendo in Zustand dati arrivati da una API, probabilmente non ti serve Zustand: ti serve una libreria di data fetching.
Il pattern che vedo di continuo è questo:
// no: lo store diventa una cache scritta a mano, male
const useUtenti = create((set) => ({
utenti: [],
caricamento: false,
carica: async () => {
set({ caricamento: true });
const res = await fetch("/api/utenti");
set({ utenti: await res.json(), caricamento: false });
},
}));
Funziona finché non ti chiedi: cosa succede se due componenti chiamano carica insieme? Quando questi dati diventano vecchi? Chi li ricarica dopo che l'utente ne modifica uno da un'altra pagina? Cosa succede se la richiesta fallisce? E se l'utente torna sulla scheda dopo dieci minuti?
Da lì in poi stai costruendo a mano una cache con invalidazione, deduplica delle richieste e gestione degli errori. Esiste già, è testata da anni, ed è spiegata in cos'è TanStack Query. Zustand resta per quel poco che è davvero stato del client — e di solito, dopo aver tolto i dati del server, quel poco sta in un file da trenta righe.
In sintesi
Zustand è stato client globale senza provider e senza boilerplate: uno store è un modulo, e chi lo importa lo usa.
Il selettore è tutto. Leggere lo store intero ti riporta al comportamento di Context; leggere una fetta è il motivo per cui hai installato la libreria.
Non salvare valori derivati. Tieni la verità minima nello store e calcola il resto nel componente.
Context non è il nemico. Per valori che cambiano raramente resta la scelta più semplice, e una dipendenza in meno vale qualcosa.
Se dentro lo store ci sono dati del server, hai sbagliato strumento. Quello è un problema di cache, non di stato.
Per capire prima quale problema stai risolvendo, parti da gestione dello stato in React; per le basi di componenti e hook, cos'è React. Con TypeScript gli store diventano molto più sicuri da modificare.