È uscito il Corso Java Completo
Torna al blog

Cos'è TanStack Query e perché cambia tutto

Cos'è TanStack Query, il problema che risolve per lo stato del server e come funzionano query key, cache, dati stantii, invalidazione e mutazioni in React.

Edoardo Midali

Edoardo Midali

Developer · Content Creator

9 min di lettura

Hai scritto mille volte quelle quindici righe: useState per i dati, useState per il caricamento, useState per l'errore, useEffect con il fetch dentro. Funziona, lo copi nel componente successivo, e non ti accorgi di tutto quello che non fa. In questo articolo trovi cosa manca davvero a quel codice, come funzionano query key, cache e invalidazione, e perché TanStack Query non sostituisce affatto una libreria di stato globale.

Cos'è

TanStack Query è una libreria che gestisce lo stato del server in React: si occupa di recuperare i dati da una API, tenerli in cache, sapere quando sono vecchi, ricaricarli e condividerli tra i componenti, al posto tuo.

Si chiamava React Query e il nome vecchio circola ancora. Oggi funziona anche con Vue, Svelte e altri, da cui il rebranding.

Il problema, per davvero

Partiamo dal codice che tutti abbiamo scritto:

function Utenti() {
  const [utenti, setUtenti] = useState([]);
  const [caricamento, setCaricamento] = useState(true);
  const [errore, setErrore] = useState(null);

  useEffect(() => {
    fetch("/api/utenti")
      .then((r) => r.json())
      .then(setUtenti)
      .catch(setErrore)
      .finally(() => setCaricamento(false));
  }, []);

  if (caricamento) return <p>Caricamento…</p>;
  if (errore) return <p>Errore</p>;
  return <Lista utenti={utenti} />;
}

Non è sbagliato. È incompleto, e l'elenco di ciò che manca è più lungo del codice stesso.

Niente cache. Vai alla pagina, torni indietro, torni avanti: tre richieste identiche e tre attese. Il visitatore guarda uno spinner per dati che aveva già visto due secondi prima.

Richieste duplicate. Se tre componenti diversi nella stessa pagina hanno bisogno dell'utente corrente, partono tre chiamate HTTP simultanee per la stessa risorsa.

Nessuna nozione di dato vecchio. I dati caricati all'apertura restano lì per sempre. L'utente tiene la scheda aperta un'ora, torna, e sta guardando informazioni di un'ora fa senza saperlo.

Nessun nuovo tentativo. Una connessione mobile che perde un pacchetto e la pagina è rotta fino al ricaricamento manuale.

Nessun aggiornamento in background. Non c'è modo di dire "mostrami subito quello che hai e intanto controlla se è cambiato".

Race condition. Questa è la più insidiosa. Cambi filtro velocemente: parte la richiesta A, poi la B. La B risponde per prima, poi arriva la A e sovrascrive. Vedi i risultati del filtro vecchio, con quello nuovo selezionato. Nessuno se ne accorge in sviluppo su localhost.

Nessuna pulizia. Se il componente si smonta prima che la risposta arrivi, chiami setState su un componente morto.

Ognuna di queste si può risolvere a mano. Il costo nascosto non è risolverle: è risolverle tutte, ogni volta, in ogni componente, senza sbagliarne una. Quel codice diventa duecento righe e nessuno lo scrive davvero. È lì che una libreria ripaga.

Il minimo per partire

Un provider in cima e uno hook nei componenti.

// main.jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Applicazione />
    </QueryClientProvider>
  );
}
import { useQuery } from "@tanstack/react-query";

function Utenti() {
  const { data, isPending, isError } = useQuery({
    queryKey: ["utenti"],
    queryFn: async () => {
      const res = await fetch("/api/utenti");
      if (!res.ok) throw new Error("Richiesta fallita");
      return res.json();
    },
  });

  if (isPending) return <p>Caricamento…</p>;
  if (isError) return <p>Errore</p>;
  return <Lista utenti={data} />;
}

Stessa lunghezza del codice di prima, e tutti i problemi dell'elenco sopra sono già gestiti. Nota il throw dentro queryFn: fetch non lancia eccezioni sugli errori HTTP, quindi senza quel controllo un 500 verrebbe trattato come successo. È il dettaglio che sfugge più spesso.

La query key

La query key è l'identificatore del dato nella cache. Tutto ruota intorno a questo.

Due componenti con la stessa chiave condividono lo stesso risultato: una sola richiesta, entrambi serviti. Chiavi diverse sono voci di cache diverse.

La regola pratica è una sola: tutto quello che entra nella queryFn deve comparire nella chiave.

// no: cambio pagina o filtro e la chiave resta identica → dati sbagliati dalla cache
useQuery({
  queryKey: ["prodotti"],
  queryFn: () => getProdotti(pagina, categoria),
});

// sì: la chiave descrive esattamente cosa contiene
useQuery({
  queryKey: ["prodotti", { pagina, categoria }],
  queryFn: () => getProdotti(pagina, categoria),
});

Quando la chiave cambia, la query riparte da sola. Non ti serve useEffect per rifare il fetch, mai: cambi la variabile che sta nella chiave e il resto avviene automaticamente.

Conviene strutturare le chiavi dal generale al particolare — ["prodotti"], ["prodotti", id], ["prodotti", { filtri }] — perché l'invalidazione lavora per prefisso: invalidare ["prodotti"] colpisce tutte le query che iniziano così.

Fresh e stale

Sono i due stati in cui può trovarsi un dato in cache, e capirli spiega quasi tutto il comportamento della libreria.

Un dato fresh è considerato attuale: se un componente lo chiede, arriva dalla cache e non parte nessuna richiesta di rete.

Un dato stale (stantio) è ancora usabile e viene mostrato subito, ma la libreria ne approfitta per ricontrollarlo in background alla prima occasione: quando torni sulla scheda, quando il componente si rimonta, quando la connessione torna.

A regolare il confine è staleTime, che di default è zero — cioè i dati sono considerati stantii appena arrivati.

useQuery({
  queryKey: ["profilo"],
  queryFn: getProfilo,
  staleTime: 5 * 60 * 1000, // fresh per 5 minuti
});

Se ti sembra che TanStack Query faccia "troppe richieste", quasi sempre è perché staleTime è rimasto a zero su dati che cambiano una volta al giorno. Alzalo in base a quanto in fretta quel dato invecchia davvero: un listino prezzi non è una chat.

C'è poi gcTime, diverso e spesso confuso: è per quanto tempo un dato che nessun componente sta più usando resta in memoria prima di essere buttato. staleTime riguarda la freschezza, gcTime la conservazione. Il concetto generale di scadenza è lo stesso di cos'è il caching.

Le mutazioni e l'invalidazione

Leggere è metà del lavoro. Quando l'utente scrive qualcosa si usa useMutation, e subito dopo si dice alla cache che una parte di essa non è più valida.

import { useMutation, useQueryClient } from "@tanstack/react-query";

function FormNuovoUtente() {
  const queryClient = useQueryClient();

  const { mutate, isPending } = useMutation({
    mutationFn: (nuovo) =>
      fetch("/api/utenti", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(nuovo),
      }).then((r) => r.json()),

    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["utenti"] });
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        mutate({ nome: e.target.nome.value });
      }}
    >
      <input name="nome" />
      <button disabled={isPending}>Salva</button>
    </form>
  );
}

invalidateQueries è il meccanismo centrale. Non dici a nessun componente di aggiornarsi: segni una porzione di cache come non più valida, e ogni componente che la sta usando ricarica da solo. Non importa quanto sia lontano nell'albero, non importa che tu sappia che esiste.

È il punto in cui il modello smette di somigliare alla gestione dello stato tradizionale. Non propaghi aggiornamenti: invalidi.

Aggiornamento ottimistico

Per le azioni che quasi sempre riescono — un "mi piace", una spunta su una checklist — puoi aggiornare la cache prima della risposta del server e annullare se fallisce. Si fa con onMutate, onError e onSettled.

Ha un costo in complessità e serve un rollback corretto, quindi vale la pena solo dove l'attesa si percepisce davvero. Per il 90% delle mutazioni, invalidateQueries in onSuccess è la scelta giusta.

Non è una libreria di stato globale

Questo fraintendimento manda fuori strada parecchia gente.

TanStack Query non sostituisce Zustand, Redux o Context, e non è un posto dove mettere "lo stato dell'applicazione".

TanStack QueryZustand / Redux / Context
Che stato gestisceDel server: copia di dati remotiDel client: esiste solo nel browser
Chi possiede la veritàIl database, dall'altra parteLa tua applicazione
Problema principaleSincronia, cache, freschezzaCondivisione e rirender
EsempiUtenti, ordini, articoli, profiloMenu aperto, carrello locale, tema

Nella pratica convivono benissimo, e in una app tipica il 90% dei dati sta nel primo e il 10% nel secondo. Il ragionamento completo su come distinguerli è in gestione dello stato in React.

Gli errori che fanno perdere ore

Query key incomplete. La causa numero uno di "vedo dati vecchi". Se queryFn usa una variabile, quella variabile sta nella chiave. Senza eccezioni.

useEffect per rifare il fetch. Se ti ritrovi a scrivere useEffect(() => refetch(), [filtro]), stai lottando contro la libreria: metti filtro nella chiave e cancella l'effetto. Il motivo per cui useEffect è quasi sempre lo strumento sbagliato è spiegato in cos'è React.

Copiare data in uno useState. L'errore che vanifica tutto:

// no: adesso hai una copia scollegata e la cache non serve più a niente
const { data } = useQuery({ queryKey: ["utenti"], queryFn: getUtenti });
const [utenti, setUtenti] = useState([]);
useEffect(() => setUtenti(data ?? []), [data]);

Usa data direttamente. Se ti serve per pre-riempire un form modificabile, non copiarlo in uno stato con un effetto: usa una key sul componente del form così che si rimonti quando l'elemento cambia.

staleTime a zero su dati stabili. Poi ci si lamenta delle troppe richieste.

Mettere tutto in una query sola. ["dashboard"] che restituisce un oggetto gigante sembra efficiente e non lo è: non puoi invalidare un pezzo senza ricaricare tutto. Query separate per risorse separate.

Non gestire gli errori HTTP. Senza il controllo su res.ok, un 404 finisce nella cache come se fosse un dato valido. Se il tema delle risposte HTTP ti è poco chiaro, il riferimento è cos'è una REST API.

E se uso i Server Component?

Domanda legittima. Con Next.js e l'App Router molti dati si caricano direttamente sul server con un await, senza cache client e senza stati di caricamento.

Dove i dati si leggono e basta, quella è la strada più semplice e TanStack Query non aggiunge nulla. Resta utile quando l'interfaccia è viva nel browser: liste che si aggiornano da sole, scorrimento infinito, filtri che cambiano di continuo, dati condivisi tra componenti client lontani, aggiornamenti ottimistici. E resta la scelta standard in una SPA costruita con Vite, dove server non ce n'è.

In sintesi

TanStack Query gestisce lo stato del server, cioè la copia locale di dati che vivono altrove. Non è una libreria di stato globale.

Il vero costo di fetch + useState + useEffect è tutto ciò che non fa: cache, deduplica, freschezza, nuovi tentativi, aggiornamento in background e race condition.

La query key è la chiave della cache. Tutto ciò che la queryFn usa deve comparirci dentro.

Non propaghi aggiornamenti: invalidi. Dopo una mutazione segni la porzione di cache come non valida e chi la usa si riallinea da solo.

Non copiare data in uno stato locale. È il modo più rapido per buttare via il motivo per cui hai installato la libreria.

Per capire quale tipo di stato hai per le mani prima di scegliere lo strumento, parti da gestione dello stato in React; per la parte client globale, cos'è Zustand.