Introduzione: perché la gestione dello stato è così importante in React Native
Se avete lavorato su un'app React Native che va oltre il classico "Hello World", sapete benissimo di cosa sto parlando. La gestione dello stato — cioè come i dati vengono condivisi, aggiornati e sincronizzati tra componenti, schermate e layer dell'architettura — è uno di quei problemi che sembra semplice finché non lo affrontate davvero.
Pensateci un attimo: un'app di e-commerce deve tenere il carrello sincronizzato tra la lista prodotti, la pagina di dettaglio e il checkout. Un'app social deve aggiornare le notifiche in tempo reale, gestire la sessione utente e cacheare dati per l'uso offline. Senza una strategia solida, vi ritroverete con componenti che non si aggiornano, dati duplicati ovunque, re-render inutili e utenti che (giustamente) si lamentano.
Nel 2026 il panorama è cambiato parecchio rispetto a qualche anno fa. La New Architecture è diventata lo standard — Expo SDK 53 l'ha resa predefinita — e con React 19 disponibile nativamente abbiamo strumenti e pattern completamente nuovi. Ma la cosa più interessante è un'altra: la community si è spostata verso soluzioni più leggere, performanti e con molto meno boilerplate.
In questa guida esploreremo le tre librerie di stato più rilevanti del momento — Zustand, Jotai e Legend-State — insieme all'intramontabile Context API di React. Vedremo codice reale, pattern avanzati, persistenza con MMKV e considerazioni specifiche per le performance mobile. Che siate sviluppatori intermedi in cerca di chiarezza o veterani che vogliono aggiornarsi, qui troverete quello che vi serve.
Prima di tuffarci nelle singole librerie, un po' di contesto. Con Expo SDK 53, la New Architecture (Fabric per il rendering, TurboModules per i moduli nativi, Bridgeless Mode) è l'impostazione predefinita. Il vecchio Bridge — quel famigerato collo di bottiglia tra JavaScript e il layer nativo — è stato finalmente mandato in pensione. React 19 porta con sé il nuovo hook use() per consumare promesse e contesti direttamente, un Suspense molto più maturo e le Owner Stack per un debugging più preciso. Tutti cambiamenti che hanno un impatto diretto su come gestiamo lo stato.
Il panorama dello stato nel 2026: oltre Redux
Per anni Redux è stato il re indiscusso della gestione dello stato in React e React Native. Store centralizzato, azioni, reducer, middleware — un'architettura robusta ma, diciamolo, notoriamente verbosa. Aggiungere una semplice proprietà allo stato significava toccare almeno tre file e scrivere decine di righe di boilerplate.
Intendiamoci: Redux Toolkit ha migliorato parecchio l'esperienza, e Redux resta una scelta valida per applicazioni enterprise complesse. Ma la tendenza nel 2026 è chiara: la maggior parte dei nuovi progetti React Native non usa più Redux. Zustand è cresciuto di oltre il 30% anno su anno e compare ormai nel 40% dei nuovi progetti.
Perché questo cambiamento? Diversi fattori:
- Bundle size — su mobile ogni kilobyte conta. Zustand pesa ~3KB, Jotai ~3.5KB, Legend-State ~4KB. Redux Toolkit con le sue dipendenze supera facilmente i 40KB.
- Boilerplate minimo — le librerie moderne richiedono una frazione del codice necessario con Redux.
- React 19 e i nuovi pattern — con
use(), Suspense migliorato e le Server Functions, il confine tra stato del server e stato del client si è ridefinito. TanStack Query gestisce lo stato del server, mentre Zustand e Jotai si occupano dello stato client. - New Architecture — il rendering sincrono di Fabric e i TurboModules hanno cambiato le dinamiche di performance, rendendo la reattività fine-grained di Legend-State ancora più interessante.
- Approccio ibrido — nel 2026 la best practice è combinare più soluzioni specializzate anziché forzare tutto in un unico store monolitico.
C'è poi un altro fattore importante: la maturazione del concetto di stato del server vs stato del client. Oggi è abbastanza chiaro che i dati provenienti da API non dovrebbero vivere in uno store globale come si faceva con Redux. Librerie come TanStack Query gestiscono caching, invalidazione e refetching in modo specializzato e molto più efficace. Lo store client — Zustand, Jotai o Legend-State — si occupa di ciò che è genuinamente locale: preferenze utente, stato dell'interfaccia, navigazione, dati offline.
Ok, basta premesse. Entriamo nel vivo.
Zustand: lo stato senza cerimonie
Zustand (dal tedesco "stato") è una libreria creata dal team pmndrs — gli stessi di Jotai, React Three Fiber e Valtio. Con ~3KB di peso, è la soluzione più popolare nell'ecosistema React Native nel 2026, e onestamente il motivo è semplice: fa quello che serve senza complicazioni.
Setup e primo store
Installazione con un singolo comando:
npx expo install zustand
Ecco come si crea uno store Zustand con TypeScript — notate quanto è più snello rispetto a Redux:
// stores/useAuthStore.ts
import { create } from 'zustand';
interface User {
id: string;
name: string;
email: string;
avatar: string | null;
}
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
updateProfile: (data: Partial<User>) => void;
}
export const useAuthStore = create<AuthState>()((set, get) => ({
user: null,
token: null,
isLoading: false,
login: async (email, password) => {
set({ isLoading: true });
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const { user, token } = await response.json();
set({ user, token, isLoading: false });
} catch (error) {
set({ isLoading: false });
throw error;
}
},
logout: () => set({ user: null, token: null }),
updateProfile: (data) => {
const currentUser = get().user;
if (currentUser) {
set({ user: { ...currentUser, ...data } });
}
},
}))
Notate la sintassi create<AuthState>()(... con le doppie parentesi — è la forma richiesta da Zustand v5 per il corretto type inference con TypeScript. Lo store contiene sia i dati che le azioni in un unico oggetto, niente più separazione tra azioni e reducer come in Redux.
Utilizzo nei componenti
Usare lo store è immediato — è un normalissimo hook React:
// screens/ProfileScreen.tsx
import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native';
import { useAuthStore } from '../stores/useAuthStore';
export function ProfileScreen() {
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
if (!user) return null;
return (
<View style={{ flex: 1, padding: 16 }}>
<Text style={{ fontSize: 24, fontWeight: 'bold' }}>
{user.name}
</Text>
<Text style={{ color: '#666', marginTop: 4 }}>
{user.email}
</Text>
<TouchableOpacity
onPress={logout}
style={{ marginTop: 24, padding: 12, backgroundColor: '#e74c3c', borderRadius: 8 }}
>
<Text style={{ color: '#fff', textAlign: 'center' }}>Esci</Text>
</TouchableOpacity>
</View>
);
}
Un aspetto fondamentale: ogni chiamata a useAuthStore con un selettore diverso crea una sottoscrizione distinta. Il componente si ri-renderizza solo quando il valore selezionato cambia. Su React Native, dove ogni re-render ha un costo reale, questo fa la differenza.
Selettori e useShallow
Quando dovete estrarre più valori dallo store, useShallow è il vostro migliore amico. Senza di esso, restituire un nuovo oggetto dal selettore causa un re-render a ogni cambio dello store (anche se i valori che vi interessano non sono cambiati):
import { useShallow } from 'zustand/shallow';
// SBAGLIATO - crea un nuovo oggetto a ogni render
const { user, isLoading } = useAuthStore((state) => ({
user: state.user,
isLoading: state.isLoading,
}));
// CORRETTO - confronto shallow dell'oggetto
const { user, isLoading } = useAuthStore(
useShallow((state) => ({
user: state.user,
isLoading: state.isLoading,
}))
);
In Zustand v5 questo è diventato ancora più rilevante, perché il comportamento predefinito usa il confronto referenziale (Object.is), allineandosi con il comportamento standard di React.
Middleware e persistenza con MMKV
Una delle cose che preferisco di Zustand è il sistema di middleware componibili. Il più utile in React Native è persist, che salva automaticamente lo stato su disco. Combinandolo con MMKV — circa 30 volte più veloce di AsyncStorage — otteniamo una persistenza ad alte prestazioni che è quasi magica:
// lib/storage.ts
import { MMKV } from 'react-native-mmkv';
import { StateStorage } from 'zustand/middleware';
const mmkv = new MMKV();
export const mmkvStorage: StateStorage = {
setItem: (name, value) => mmkv.set(name, value),
getItem: (name) => mmkv.getString(name) ?? null,
removeItem: (name) => mmkv.delete(name),
};
// stores/useSettingsStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { mmkvStorage } from '../lib/storage';
interface SettingsState {
theme: 'light' | 'dark' | 'system';
language: string;
notificationsEnabled: boolean;
setTheme: (theme: 'light' | 'dark' | 'system') => void;
setLanguage: (lang: string) => void;
toggleNotifications: () => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'system',
language: 'it',
notificationsEnabled: true,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
toggleNotifications: () =>
set((state) => ({ notificationsEnabled: !state.notificationsEnabled })),
}),
{
name: 'settings-storage',
storage: createJSONStorage(() => mmkvStorage),
partialize: (state) => ({
theme: state.theme,
language: state.language,
notificationsEnabled: state.notificationsEnabled,
}),
}
)
)
La funzione partialize è particolarmente utile: vi permette di scegliere quali parti dello stato persistere, escludendo funzioni e dati temporanei. Zustand gestisce automaticamente l'idratazione — quando l'app si avvia, lo stato viene ripristinato da MMKV in modo sincrono (a differenza di AsyncStorage che è asincrono, con tutti i mal di testa che ne conseguono).
Pattern avanzato: store con slice
Per applicazioni più grandi, potete dividere lo store in "slice" mantenendo un unico store globale:
// stores/slices/cartSlice.ts
import { StateCreator } from 'zustand';
export interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
export interface CartSlice {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
clearCart: () => void;
totalPrice: () => number;
}
export const createCartSlice: StateCreator<CartSlice> = (set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
clearCart: () => set({ items: [] }),
totalPrice: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
})
Zustand brilla per la sua semplicità: nessun Provider nell'albero dei componenti, nessun boilerplate di azioni/reducer, e un pattern che rende il flusso dati prevedibile. Ma c'è un vantaggio spesso sottovalutato: lo store è accessibile anche fuori dall'albero React. Potete chiamare useAuthStore.getState() in un interceptor Axios, in un service di navigazione, o in qualsiasi modulo JS. Con la Context API questo non si può fare, e rende Zustand particolarmente adatto ad architetture con layer di servizi separati dalla UI.
Vale la pena menzionare anche il middleware devtools, che collega lo store ai Redux DevTools (funziona anche con Flipper su React Native). E per chi ama scrivere aggiornamenti mutabili, c'è il middleware immer — utile per stati profondamente annidati dove lo spread operator diventa un incubo.
Jotai: lo stato atomico
Se Zustand adotta un approccio top-down (uno store che contiene tutto), Jotai fa l'esatto opposto. Creato dallo stesso team (pmndrs), Jotai implementa un modello atomico bottom-up, ispirato a Recoil di Facebook ma con un'API decisamente più snella.
Il concetto è semplice ma potente: lo stato è composto da atomi — piccoli pezzi indipendenti che possono essere composti insieme. I componenti si sottoscrivono solo agli atomi che usano, e i re-render diventano chirurgicamente precisi.
Atomi primitivi e derivati
npx expo install jotai
Partiamo dalle basi:
// atoms/counterAtoms.ts
import { atom } from 'jotai';
// Atomo primitivo - contiene un valore semplice
export const countAtom = atom(0);
// Atomo di sola lettura (derivato)
export const doubledCountAtom = atom((get) => get(countAtom) * 2);
// Atomo con lettura e scrittura personalizzata
export const countWithMaxAtom = atom(
(get) => get(countAtom),
(get, set, newValue: number) => {
const clamped = Math.min(newValue, 100);
set(countAtom, clamped);
}
);
La bellezza di Jotai sta nella composizione. Gli atomi derivati tracciano automaticamente le dipendenze: quando countAtom cambia, doubledCountAtom viene ricalcolato, e solo i componenti che leggono doubledCountAtom si ri-renderizzano. Niente selettori manuali, niente memoizzazione — funziona e basta.
Utilizzo nei componenti React Native
// components/Counter.tsx
import { View, Text, Button } from 'react-native';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { countAtom, doubledCountAtom } from '../atoms/counterAtoms';
export function Counter() {
// useAtom restituisce [valore, setter] - come useState
const [count, setCount] = useAtom(countAtom);
// useAtomValue - solo lettura, nessun setter
const doubled = useAtomValue(doubledCountAtom);
// useSetAtom - solo scrittura, il componente NON si ri-renderizza
// quando il valore cambia (utile per bottoni/trigger)
return (
<View style={{ padding: 16 }}>
<Text style={{ fontSize: 48, textAlign: 'center' }}>{count}</Text>
<Text style={{ fontSize: 24, textAlign: 'center', color: '#666' }}>
Doppio: {doubled}
</Text>
<Button title="Incrementa" onPress={() => setCount((c) => c + 1)} />
<Button title="Reset" onPress={() => setCount(0)} />
</View>
);
}
Tre hook distinti: useAtom per lettura e scrittura, useAtomValue per sola lettura, e useSetAtom per sola scrittura. Questa granularità è fondamentale per ottimizzare i re-render — e nella mia esperienza è uno dei vantaggi più sottovalutati di Jotai.
Atomi asincroni
Jotai gestisce nativamente lo stato asincrono tramite atomi con funzioni async, il che è particolarmente comodo per il data fetching:
// atoms/userAtoms.ts
import { atom } from 'jotai';
export const userIdAtom = atom<string | null>(null);
// Atomo asincrono - si integra con Suspense
export const userProfileAtom = atom(async (get) => {
const userId = get(userIdAtom);
if (!userId) return null;
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) throw new Error('Utente non trovato');
return response.json();
});
// Atomo derivato da uno asincrono
export const userDisplayNameAtom = atom(async (get) => {
const profile = await get(userProfileAtom);
if (!profile) return 'Ospite';
return `${profile.firstName} ${profile.lastName}`;
});
// screens/UserScreen.tsx
import { Suspense } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
import { useAtomValue } from 'jotai';
import { userDisplayNameAtom } from '../atoms/userAtoms';
function UserContent() {
const displayName = useAtomValue(userDisplayNameAtom);
return <Text style={{ fontSize: 20 }}>Benvenuto, {displayName}!</Text>;
}
export function UserScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Suspense fallback={<ActivityIndicator size="large" />}>
<UserContent />
</Suspense>
</View>
);
}
Con React 19, Suspense è finalmente maturo e stabile. Gli atomi asincroni di Jotai si integrano perfettamente con questo pattern, permettendovi di separare la logica di caricamento da quella di visualizzazione in modo pulito.
Persistenza con MMKV in Jotai
Per persistere gli atomi Jotai con MMKV, creiamo un'utilità personalizzata basata su atomWithStorage:
// lib/atomWithMMKV.ts
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import { MMKV } from 'react-native-mmkv';
const mmkv = new MMKV();
function getMMKVStorage<T>() {
return createJSONStorage<T>(() => ({
getItem: (key: string) => {
const value = mmkv.getString(key);
return value ?? null;
},
setItem: (key: string, value: string) => {
mmkv.set(key, value);
},
removeItem: (key: string) => {
mmkv.delete(key);
},
}));
}
export function atomWithMMKV<T>(key: string, initialValue: T) {
return atomWithStorage<T>(key, initialValue, getMMKVStorage<T>(), {
getOnInit: true,
});
}
// Utilizzo
export const themeAtom = atomWithMMKV<'light' | 'dark'>('app-theme', 'light');
export const onboardingCompleteAtom = atomWithMMKV('onboarding-done', false);
L'opzione getOnInit: true è essenziale: fa sì che il valore venga letto dallo storage in modo sincrono all'avvio, evitando quel fastidioso flash di contenuto predefinito tipico delle soluzioni asincrone.
Quando scegliere Jotai
Jotai eccelle quando lo stato della vostra app è naturalmente componibile: tanti piccoli pezzi indipendenti che vengono combinati in modo flessibile. È ideale per form complesse, configurazioni utente distribuite, e applicazioni dove componenti diversi leggono diverse combinazioni di dati. Il modello atomico previene automaticamente i re-render non necessari — senza bisogno di selettori o memoizzazione manuale.
Un aspetto che apprezzo particolarmente è l'integrazione nativa con React Native: gli atomi funzionano identicamente su web, iOS e Android senza modifiche. TypeScript è un cittadino di prima classe, e l'inferenza dei tipi funziona alla perfezione sia per atomi primitivi che derivati. L'ecosistema di utilities è ricco — atomFamily per atomi parametrici, atomWithReset per tornare al valore iniziale, atomWithReducer per logica complessa, selectAtom per ottimizzazioni avanzate. In pratica, Jotai vi dà i blocchi costruttivi e voi assemblate la struttura che serve, senza vincoli.
Da notare: a differenza di Zustand, Jotai beneficia di un Provider per isolare lo stato in contesti diversi. Per esempio, in un'app multi-account, ogni account potrebbe avere il proprio Provider con un set separato di atomi. Il Provider è opzionale (senza, Jotai usa uno store globale), ma la possibilità di isolamento è un bel plus in scenari avanzati.
Legend-State: reattività fine-grained e performance estreme
Legend-State è la scelta per chi mette le performance al primo posto, punto. Creata da Jay Meistrich (lo stesso di Legend List, l'alternativa ad alte prestazioni a FlatList), implementa un sistema basato su signal — quel pattern di reattività fine-grained che ha conquistato la scena nel 2025-2026, ispirato da framework come SolidJS e Angular Signals.
I numeri sono impressionanti: ~4KB di peso e nei benchmark supera ogni altra libreria di stato React. In alcuni test batte persino il vanilla JavaScript nelle operazioni su array (sì, avete letto bene). Com'è possibile? La reattività fine-grained permette di aggiornare solo le parti dell'interfaccia che dipendono effettivamente dal dato modificato, bypassando il ciclo di re-render di React quando possibile.
Observable: il concetto base
npx expo install @legendapp/state
In Legend-State, tutto parte dagli observable — oggetti reattivi che notificano automaticamente i listener quando cambiano:
// state/appState.ts
import { observable, computed } from '@legendapp/state';
interface Product {
id: string;
name: string;
price: number;
inStock: boolean;
}
// Creare uno stato osservabile
export const store$ = observable({
products: [] as Product[],
searchQuery: '',
sortBy: 'name' as 'name' | 'price',
// I computed sono funzioni dentro l'observable
filteredProducts: (): Product[] => {
const query = store$.searchQuery.get().toLowerCase();
const products = store$.products.get();
const sortBy = store$.sortBy.get();
const filtered = query
? products.filter((p) => p.name.toLowerCase().includes(query))
: products;
return [...filtered].sort((a, b) =>
sortBy === 'price' ? a.price - b.price : a.name.localeCompare(b.name)
);
},
totalValue: (): number => {
return store$.products.get().reduce((sum, p) => sum + p.price, 0);
},
});
// Accesso diretto allo stato - senza hook, fuori da React
store$.products.push({
id: '1',
name: 'Espresso Maker',
price: 299,
inStock: true,
});
// Lettura diretta
console.log(store$.searchQuery.get()); // ''
// Scrittura diretta
store$.searchQuery.set('espresso');
Notate una differenza sostanziale rispetto a Zustand e Jotai: lo stato di Legend-State è accessibile e modificabile anche fuori dai componenti React, senza bisogno di hook. Questo lo rende perfetto per la logica business, i service layer e l'integrazione con sistemi nativi.
Integrazione con React Native
In Legend-State v3, il modo principale per consumare observable nei componenti è tramite use$ (o il suo alias useValue):
// screens/ProductListScreen.tsx
import { View, Text, FlatList, TextInput, StyleSheet } from 'react-native';
import { use$ } from '@legendapp/state/react';
import { store$ } from '../state/appState';
export function ProductListScreen() {
const filteredProducts = use$(store$.filteredProducts);
const searchQuery = use$(store$.searchQuery);
return (
<View style={styles.container}>
<TextInput
style={styles.searchInput}
value={searchQuery}
onChangeText={(text) => store$.searchQuery.set(text)}
placeholder="Cerca prodotti..."
/>
<FlatList
data={filteredProducts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.productCard}>
<Text style={styles.productName}>{item.name}</Text>
<Text style={styles.productPrice}>€{item.price.toFixed(2)}</Text>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
searchInput: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
marginBottom: 16,
fontSize: 16,
},
productCard: {
flexDirection: 'row',
justifyContent: 'space-between',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
productName: { fontSize: 16, fontWeight: '600' },
productPrice: { fontSize: 16, color: '#2ecc71' },
});
Componenti reattivi: zero re-render
Ed eccoci alla vera potenza di Legend-State — i componenti reattivi, che aggiornano il DOM nativo direttamente senza passare per il ciclo di re-render di React:
// Con componenti reattivi di Legend-State per React Native
import { Reactive } from '@legendapp/state/react';
import { Text, TextInput } from 'react-native';
// Configurare componenti React Native come reattivi
const ReactiveText = Reactive.Text;
const ReactiveTextInput = Reactive.TextInput;
export function ReactivePriceDisplay() {
// Questo componente NON si ri-renderizza mai dopo il mount.
// Il testo viene aggiornato direttamente tramite il binding reattivo.
return (
<View>
<ReactiveText
$style={() => ({
fontSize: 32,
color: store$.totalValue.get() > 1000 ? '#e74c3c' : '#2ecc71',
})}
>
{() => `Totale: €${store$.totalValue.get().toFixed(2)}`}
</ReactiveText>
<ReactiveTextInput
$value={store$.searchQuery}
$placeholder={() => 'Cerca...'}
style={{ borderWidth: 1, padding: 8, borderRadius: 4 }}
/>
</View>
);
}
Il prefisso $ sulle props indica un binding reattivo bidirezionale. Quando store$.searchQuery cambia, il TextInput si aggiorna senza che React rifaccia il render del componente. Su mobile, dove ogni re-render ha un costo misurabile, questo fa davvero la differenza.
Sistema di sincronizzazione
Legend-State include un sistema di sync integrato che supporta backend come Supabase, Firebase e qualsiasi API REST. Può funzionare anche in modalità local-first con persistenza automatica:
import { observable } from '@legendapp/state';
import { syncObservable } from '@legendapp/state/sync';
import { ObservablePersistMMKV } from '@legendapp/state/persist-plugins/mmkv';
const todos$ = observable({ items: [] });
syncObservable(todos$, {
persist: {
name: 'todos',
plugin: ObservablePersistMMKV,
},
// Opzionale: sync con un backend
get: async () => {
const response = await fetch('https://api.example.com/todos');
return await response.json();
},
set: async ({ value }) => {
await fetch('https://api.example.com/todos', {
method: 'PUT',
body: JSON.stringify(value),
});
},
});
Legend-State è la scelta giusta quando le performance sono la priorità assoluta: animazioni complesse, liste enormi, dashboard con aggiornamenti frequenti. Il modello a signal richiede un po' di cambio di mentalità rispetto al classico React, ma i benefici sono tangibili e misurabili.
Se trovate esempi online che usano observer() come HOC o useSelector, probabilmente fanno riferimento alla v2 — aggiornateli. Legend-State v3 è più snella e idiomatica, con un'API che risulta naturale sia per chi arriva da React che per chi conosce framework reattivi come SolidJS o Vue.
Un punto di forza spesso trascurato è il sistema di history tracking. Potete abilitare la cronologia delle modifiche su qualsiasi observable, ottenendo undo/redo praticamente gratis. Per app di editing — testo, immagini, configurazioni — questo è un vantaggio enorme che altrimenti richiederebbe settimane di lavoro.
React Context API: quando basta e quando no
Prima di correre a installare librerie esterne, fermatevi un secondo: la Context API di React è sufficiente per il vostro caso? A volte la risposta è sì, e saperlo vi eviterà dipendenze inutili.
Quando la Context è la scelta giusta
La Context API funziona benissimo per lo stato che cambia raramente e deve essere accessibile da molti componenti:
- Tema dell'applicazione (dark/light mode)
- Localizzazione e lingua corrente
- Configurazione dell'utente autenticato
- Feature flags
// context/ThemeContext.tsx
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { useColorScheme } from 'react-native';
type Theme = 'light' | 'dark';
interface ThemeColors {
background: string;
text: string;
primary: string;
card: string;
border: string;
}
interface ThemeContextValue {
theme: Theme;
colors: ThemeColors;
toggleTheme: () => void;
}
const lightColors: ThemeColors = {
background: '#ffffff',
text: '#1a1a1a',
primary: '#3498db',
card: '#f8f9fa',
border: '#e9ecef',
};
const darkColors: ThemeColors = {
background: '#1a1a1a',
text: '#f8f9fa',
primary: '#5dade2',
card: '#2d2d2d',
border: '#404040',
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const systemColorScheme = useColorScheme();
const [theme, setTheme] = useState<Theme>(systemColorScheme ?? 'light');
const toggleTheme = useCallback(() => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
}, []);
const colors = theme === 'light' ? lightColors : darkColors;
return (
<ThemeContext.Provider value={{ theme, colors, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme deve essere usato dentro ThemeProvider');
}
return context;
}
Le trappole di performance della Context
Il problema classico della Context è noto: quando il valore del Provider cambia, tutti i componenti che consumano quel contesto si ri-renderizzano, anche se leggono solo una parte del valore che non è cambiata. Non esiste un meccanismo di selezione come i selettori di Zustand.
// PROBLEMA: questo Provider causa re-render in cascata
function AppProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
const [settings, setSettings] = useState({});
const [notifications, setNotifications] = useState([]);
// Ogni cambio a user, settings O notifications
// ri-renderizza TUTTI i consumatori
return (
<AppContext.Provider value={{ user, settings, notifications, setUser, setSettings }}>
{children}
</AppContext.Provider>
);
}
// SOLUZIONE: dividere in contesti separati
function Providers({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<SettingsProvider>
<NotificationsProvider>
{children}
</NotificationsProvider>
</SettingsProvider>
</AuthProvider>
);
}
Un pattern che funziona bene è separare lo stato dalle funzioni di aggiornamento in due contesti distinti:
// context/AuthContext.tsx - Pattern stato/dispatch separati
const AuthStateContext = createContext<AuthState | null>(null);
const AuthDispatchContext = createContext<AuthDispatch | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(authReducer, initialState);
return (
<AuthStateContext.Provider value={state}>
<AuthDispatchContext.Provider value={dispatch}>
{children}
</AuthDispatchContext.Provider>
</AuthStateContext.Provider>
);
}
// I componenti che chiamano solo dispatch non si ri-renderizzano
// quando lo stato cambia
export const useAuthState = () => useContext(AuthStateContext);
export const useAuthDispatch = () => useContext(AuthDispatchContext);
In sintesi: usate la Context per stato globale che cambia raramente. Per tutto il resto, passate a Zustand, Jotai o Legend-State. Un test pratico: se il vostro contesto cambia più di una volta al secondo (un timer, una posizione GPS, lo stato di un player audio), la Context è quasi certamente la scelta sbagliata.
Confronto: quale libreria per quale scenario
Arrivati a questo punto, facciamo un confronto diretto:
| Caratteristica | Zustand | Jotai | Legend-State | Context API |
|---|---|---|---|---|
| Dimensione bundle | ~3KB | ~3.5KB | ~4KB | 0KB (built-in) |
| Modello | Store centralizzato | Atomico bottom-up | Signal/Observable | Provider/Consumer |
| Boilerplate | Minimo | Minimo | Minimo | Medio |
| TypeScript | Eccellente | Eccellente | Buono | Buono |
| Curva di apprendimento | Bassa | Bassa-Media | Media | Bassa |
| Accesso fuori da React | Sì (getState) | Sì (store) | Sì (nativo) | No |
| Persistenza integrata | Middleware persist | atomWithStorage | Plugin sync | Manuale |
| Re-render optimization | Selettori | Automatica (atomi) | Fine-grained (signal) | Nessuna |
| DevTools | Redux DevTools | Jotai DevTools | Plugin DevTools | React DevTools |
| Comunità (2026) | Molto grande | Grande | In crescita | Universale |
| Provider necessario | No | Opzionale | No | Sì |
Regole pratiche per la scelta
Ecco le linee guida che mi sento di dare dopo aver usato tutte queste librerie in produzione:
- Usate Zustand se volete la soluzione più pragmatica e col miglior rapporto semplicità/potenza. È la scelta "sicura" per il 90% dei progetti. Intuitivo per chi viene da Redux, ma con una frazione del codice.
- Usate Jotai se la vostra app ha molti pezzi di stato indipendenti che vengono combinati in modi diversi. Form builder, dashboard configurabili, app con tante impostazioni utente — Jotai brilla qui.
- Usate Legend-State se le performance sono la vostra priorità numero uno. App finanziarie con dati in tempo reale, giochi, app con animazioni pesanti. Oppure se volete un sistema di sync local-first integrato.
- Usate la Context API per stato globale semplice che cambia raramente: tema, locale, autenticazione. Non servono librerie per queste cose.
- Combinate le soluzioni — e qui sta la vera lezione. Context per il tema, Zustand per lo stato dell'app, TanStack Query per i dati del server. Non esiste la soluzione unica per tutto, e va bene così.
Pattern avanzati: combinare le soluzioni
Nella pratica, le app React Native di produzione nel 2026 raramente usano una sola libreria di stato. Ecco un'architettura composita che funziona bene nei progetti reali:
Architettura a strati
// LAYER 1: Context API - stato che cambia raramente
// context/AppProviders.tsx
export function AppProviders({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<LocaleProvider>
<AuthProvider>
{children}
</AuthProvider>
</LocaleProvider>
</ThemeProvider>
);
}
// LAYER 2: Zustand - stato client globale
// stores/useAppStore.ts
export const useAppStore = create<AppState>()(
persist(
(set) => ({
onboardingComplete: false,
lastSync: null,
offlineQueue: [],
setOnboardingComplete: () => set({ onboardingComplete: true }),
addToOfflineQueue: (action) =>
set((s) => ({ offlineQueue: [...s.offlineQueue, action] })),
clearOfflineQueue: () => set({ offlineQueue: [] }),
}),
{
name: 'app-store',
storage: createJSONStorage(() => mmkvStorage),
}
)
);
// LAYER 3: TanStack Query - stato del server
// hooks/useProducts.ts
export function useProducts(categoryId: string) {
return useQuery({
queryKey: ['products', categoryId],
queryFn: () => fetchProducts(categoryId),
staleTime: 5 * 60 * 1000, // 5 minuti
});
}
Strategia di persistenza unificata con MMKV
Un pattern che consiglio caldamente è centralizzare tutta la persistenza attraverso un layer MMKV condiviso:
// lib/persistenceLayer.ts
import { MMKV } from 'react-native-mmkv';
// Istanze MMKV separate per diversi tipi di dati
const mainStorage = new MMKV({ id: 'main' });
const secureStorage = new MMKV({
id: 'secure',
encryptionKey: 'your-encryption-key',
});
const cacheStorage = new MMKV({ id: 'cache' });
export const StorageKeys = {
AUTH_TOKEN: 'auth:token',
USER_PROFILE: 'auth:profile',
SETTINGS: 'app:settings',
ONBOARDING: 'app:onboarding',
CACHED_PRODUCTS: 'cache:products',
OFFLINE_QUEUE: 'app:offline-queue',
} as const;
// Storage adapter per Zustand - dati principali
export const mainZustandStorage = {
setItem: (name: string, value: string) => mainStorage.set(name, value),
getItem: (name: string) => mainStorage.getString(name) ?? null,
removeItem: (name: string) => mainStorage.delete(name),
};
// Storage adapter per dati sensibili (token, credenziali)
export const secureZustandStorage = {
setItem: (name: string, value: string) => secureStorage.set(name, value),
getItem: (name: string) => secureStorage.getString(name) ?? null,
removeItem: (name: string) => secureStorage.delete(name),
};
// Utility per pulire la cache senza toccare i dati utente
export function clearCache() {
cacheStorage.clearAll();
}
// Utility per il logout completo
export function clearAllUserData() {
secureStorage.clearAll();
mainStorage.clearAll();
cacheStorage.clearAll();
}
Questa separazione in istanze MMKV distinte è una best practice che vale la pena adottare sin dall'inizio. I dati sensibili (token, sessioni, informazioni personali) vanno in uno storage criptato con chiave AES. I dati dell'app vanno in uno storage standard per massimizzare le prestazioni. La cache va in uno storage dedicato che potete svuotare in qualsiasi momento senza conseguenze.
Per chi non lo conoscesse, MMKV è stato sviluppato originariamente da WeChat per gestire miliardi di operazioni di storage al giorno. È costruito su memory-mapped files, il che significa letture e scritture direttamente in memoria, con il sistema operativo che si occupa della sincronizzazione su disco. Risultato: prestazioni fino a 30 volte superiori rispetto ad AsyncStorage, con garanzia di consistenza anche in caso di crash. Il pacchetto react-native-mmkv di Marc Rousavy offre un'API sincrona — niente più await per leggere un valore, il che semplifica enormemente il codice e elimina quei problemi di idratazione asincrona che chiunque abbia usato AsyncStorage conosce fin troppo bene.
Pattern: store Zustand che reagisce allo stato di rete
// stores/useNetworkStore.ts
import { create } from 'zustand';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
interface NetworkState {
isConnected: boolean;
connectionType: string | null;
init: () => () => void;
}
export const useNetworkStore = create<NetworkState>()((set) => ({
isConnected: true,
connectionType: null,
init: () => {
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
set({
isConnected: state.isConnected ?? false,
connectionType: state.type,
});
});
return unsubscribe;
},
}));
// Inizializzazione nel componente root
// App.tsx
import { useEffect } from 'react';
import { useNetworkStore } from './stores/useNetworkStore';
export default function App() {
const init = useNetworkStore((s) => s.init);
useEffect(() => {
const unsubscribe = init();
return unsubscribe;
}, [init]);
return <AppProviders>...</AppProviders>;
}
Questo pattern mostra come Zustand possa fare da ponte tra eventi nativi (il cambio di connettività) e il layer React, mantenendo lo stato di rete accessibile ovunque nell'app. Semplice, pulito, efficace.
Performance in React Native: considerazioni pratiche
La gestione dello stato ha un impatto diretto sulle performance percepite. Con la New Architecture e Fabric come default nel 2026, il rendering è più efficiente, ma il principio di fondo non cambia: meno re-render = app più fluida. Sembra banale, ma è davvero così.
Il problema dei re-render nelle liste
Il caso più critico è FlatList (o FlashList di Shopify, o LegendList). Quando lo stato globale cambia e il componente che contiene la lista si ri-renderizza, tutti gli item visibili vengono ri-renderizzati — con centinaia di elementi, il lag si fa sentire.
// SBAGLIATO - tutto il componente si ri-renderizza ad ogni cambio
function ProductList() {
const store = useAppStore(); // sottoscrizione all'intero store!
return (
<FlatList
data={store.products}
renderItem={({ item }) => <ProductCard product={item} />}
/>
);
}
// CORRETTO - selettore preciso, solo i dati necessari
function ProductList() {
const products = useAppStore((s) => s.products);
return (
<FlatList
data={products}
renderItem={({ item }) => <ProductCard product={item} />}
keyExtractor={(item) => item.id}
/>
);
}
// ANCORA MEGLIO - componente item memoizzato
const ProductCard = memo(function ProductCard({ product }: { product: Product }) {
return (
<View style={styles.card}>
<Text>{product.name}</Text>
<Text>€{product.price}</Text>
</View>
);
});
Strategie per libreria
Zustand: usate sempre selettori specifici. Meglio più chiamate a useStore con selettori singoli che un singolo selettore che restituisce un oggetto. Quando serve un oggetto, avvolgete con useShallow.
// Pattern ottimale per Zustand in componenti con liste
function OrderScreen() {
// Selettori atomici - ri-renderizza solo se il valore cambia
const items = useOrderStore((s) => s.items);
const total = useOrderStore((s) => s.total);
const isLoading = useOrderStore((s) => s.isLoading);
// L'azione non causa re-render (riferimento stabile)
const removeItem = useOrderStore((s) => s.removeItem);
return (
<View>
<FlatList
data={items}
renderItem={({ item }) => (
<OrderItem item={item} onRemove={removeItem} />
)}
keyExtractor={(item) => item.id}
/>
{!isLoading && <Text>Totale: €{total}</Text>}
</View>
);
}
Jotai: il modello atomico previene naturalmente i re-render inutili. Ogni atomo è una sottoscrizione indipendente. Create atomi granulari e componeteli tramite atomi derivati:
// Pattern ottimale per Jotai
const orderItemsAtom = atom<OrderItem[]>([]);
const orderTotalAtom = atom((get) =>
get(orderItemsAtom).reduce((sum, item) => sum + item.price * item.qty, 0)
);
// Atomo per un singolo item - perfetto per evitare re-render della lista
const orderItemAtomFamily = atomFamily((id: string) =>
atom(
(get) => get(orderItemsAtom).find((item) => item.id === id),
(get, set, update: Partial<OrderItem>) => {
set(orderItemsAtom, (items) =>
items.map((item) =>
item.id === id ? { ...item, ...update } : item
)
);
}
)
);
Legend-State: la reattività fine-grained è il suo punto di forza. Gli observable possono essere osservati a qualsiasi livello di profondità, e con i componenti reattivi si aggiornano le proprietà native senza re-render React:
// Pattern ottimale per Legend-State con liste
import { For } from '@legendapp/state/react';
function OptimizedProductList() {
return (
<For
each={store$.products}
item={ProductItem}
optimized
/>
);
}
// Ogni item osserva solo il proprio observable
// Se cambia il prezzo di un prodotto, SOLO quel componente si aggiorna
function ProductItem({ item$ }) {
const name = use$(item$.name);
const price = use$(item$.price);
return (
<View style={styles.item}>
<Text>{name}</Text>
<Text>€{price.toFixed(2)}</Text>
</View>
);
}
Il componente For di Legend-State è progettato per il rendering ottimizzato di liste. A differenza di FlatList, traccia ogni singola proprietà dell'observable e aggiorna solo ciò che serve. Per liste con aggiornamenti frequenti (tipo una dashboard di trading), la differenza è notevole.
Profilare le performance
Qualunque libreria scegliate, profilate sempre le performance reali. I benchmark sintetici vanno bene per i confronti, ma il collo di bottiglia nella vostra app potrebbe essere altrove. Gli strumenti principali:
- React DevTools Profiler — mostra numero e durata dei re-render per componente.
- Flipper con il plugin Performance — monitoraggio in tempo reale di frame rate e thread JS.
React.Profiler— wrappate i componenti critici per misurare i tempi di render.- Systrace su Android e Instruments su iOS — per analisi a livello nativo.
// Esempio di profilazione con React.Profiler
import { Profiler, ProfilerOnRenderCallback } from 'react';
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
if (actualDuration > 16) {
// Più di 16ms = sotto i 60fps
console.warn(
`[Perf] ${id} ha impiegato ${actualDuration.toFixed(1)}ms (${phase})`
);
}
};
function App() {
return (
<Profiler id="ProductList" onRender={onRender}>
<ProductList />
</Profiler>
);
}
Migrazione incrementale da Redux
Se avete un progetto esistente con Redux e volete migrare, niente panico — non serve farlo tutto in una volta. Ecco un approccio che funziona:
- Aggiungete Zustand senza rimuovere Redux. Le due librerie coesistono senza problemi.
- Migrate una feature alla volta: prendete uno slice Redux, riscrivetelo come store Zustand, aggiornate i componenti.
- Create un bridge temporaneo se necessario — uno store Zustand può leggere dallo store Redux durante la transizione.
- Rimuovete Redux solo quando tutto è migrato e testato.
// Esempio: bridge Redux -> Zustand durante la migrazione
import { reduxStore } from '../redux/store';
import { create } from 'zustand';
// Store Zustand che si sincronizza con Redux durante la transizione
export const useCartStore = create<CartState>()((set) => {
// Sottoscrizione allo store Redux per sincronizzare i dati
reduxStore.subscribe(() => {
const reduxState = reduxStore.getState();
set({ legacyItems: reduxState.cart.items });
});
return {
legacyItems: reduxStore.getState().cart.items,
newItems: [],
// Nuove funzionalità solo in Zustand
addNewItem: (item) =>
set((s) => ({ newItems: [...s.newItems, item] })),
};
});
Questo approccio graduale riduce il rischio e permette di testare in produzione senza un big-bang. Per un progetto medio (15-20 slice Redux), servono circa 2-3 settimane, con il vantaggio che ogni singolo passo è rilasciabile indipendentemente. Il risultato tipico? Una riduzione del 60-70% nel codice di gestione dello stato e un miglioramento misurabile nelle performance, soprattutto nel tempo di avvio.
Test dello stato
Qualunque libreria scegliate, testate lo stato. Sul serio, è una di quelle cose che fanno la differenza tra un progetto che regge e uno che crolla. Ecco come fare:
// Test per uno store Zustand
import { useAuthStore } from '../stores/useAuthStore';
describe('AuthStore', () => {
beforeEach(() => {
// Reset dello store tra i test
useAuthStore.setState({
user: null,
token: null,
isLoading: false,
});
});
it('login aggiorna user e token', async () => {
// Mock del fetch
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve({
user: { id: '1', name: 'Mario', email: '[email protected]', avatar: null },
token: 'abc123',
}),
});
await useAuthStore.getState().login('[email protected]', 'password');
const state = useAuthStore.getState();
expect(state.user?.name).toBe('Mario');
expect(state.token).toBe('abc123');
expect(state.isLoading).toBe(false);
});
it('logout resetta lo stato', () => {
useAuthStore.setState({
user: { id: '1', name: 'Mario', email: '[email protected]', avatar: null },
token: 'abc123',
});
useAuthStore.getState().logout();
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.token).toBeNull();
});
});
// Test per atomi Jotai
import { createStore } from 'jotai';
import { countAtom, doubledCountAtom } from '../atoms/counterAtoms';
describe('Counter Atoms', () => {
it('doubledCountAtom restituisce il doppio del count', () => {
const store = createStore();
store.set(countAtom, 5);
expect(store.get(doubledCountAtom)).toBe(10);
store.set(countAtom, 0);
expect(store.get(doubledCountAtom)).toBe(0);
});
});
La cosa bella è che entrambe le librerie permettono di testare lo stato in modo puro, senza renderizzare componenti React. I test sono veloci — parliamo di millisecondi — e non servono provider o ambienti di rendering. Per Legend-State, il pattern è lo stesso: create un observable, modificatelo con set(), verificate con get(). L'importante è resettare lo stato tra un test e l'altro.
Conclusione e raccomandazioni
La gestione dello stato in React Native nel 2026 è un ecosistema maturo, con soluzioni eccellenti per ogni esigenza. Il "one size fits all" dei tempi di Redux non esiste più — e sinceramente è un bene.
Per la maggior parte dei progetti, Zustand è la scelta ottimale. Semplicità, performance, ottimo supporto TypeScript e integrazione con MMKV — difficile chiedere di più. Se state iniziando un nuovo progetto e non sapete cosa scegliere, partite con Zustand. Non ve ne pentirete.
Per applicazioni con stato altamente componibile — configuratori, form builder, dashboard personalizzabili — Jotai offre un modello mentale più naturale. Gli atomi si compongono come LEGO, e i re-render si ottimizzano da soli.
Per applicazioni dove le performance sono tutto — fintech con dati in tempo reale, app con animazioni complesse, giochi — Legend-State è imbattibile grazie alla reattività fine-grained basata su signal.
Non sottovalutate la Context API per le cose semplici. Tema, localizzazione, flag utente — per queste non serve una libreria esterna.
E soprattutto: combinate le soluzioni. TanStack Query per lo stato del server, Zustand per lo stato client, Context API per la configurazione globale. Ogni strumento ha il suo dominio, e la saggezza sta nel conoscerli tutti e usare quello giusto per ogni problema.
Un ultimo consiglio: qualunque libreria scegliate, investite tempo nel profilare le performance sulla vostra app reale, su dispositivi reali. I benchmark vanno bene, ma il collo di bottiglia nella vostra app potrebbe essere un'immagine non ottimizzata, un componente non memoizzato, una query troppo frequente. Lo stato è fondamentale, ma è solo un pezzo del puzzle.
Il panorama è in una posizione eccellente. Le librerie sono mature, leggere, ben documentate. La New Architecture ha eliminato molti vecchi colli di bottiglia, e React 19 ha reso Suspense e gli aggiornamenti concorrenti una realtà stabile. Non c'è mai stato un momento migliore per costruire app React Native performanti e ben architettate.
Buona programmazione — e che i vostri re-render siano sempre minimi!