Diego Betto's Blog
Foto di D koi su Unsplash

Diego Betto · 8 settembre 2026 · 5 min di lettura

React Compiler: il bug della UI congelata nascosto tra due componenti che sembrano entrambi corretti

Un bug del React Compiler dove un componente esce dalla memoizzazione mentre il suo vicino viene memoizzato in silenzio, congelando una UI alimentata da un riferimento stabile. Causa e fix.

Condividi:XLinkedInFacebookWhatsApp

Se hai letto l’introduzione al React Compiler su questo blog, conosci già il senso: il compiler memoizza al posto tuo, quindi buona parte degli useMemo/useCallback manuali diventa superflua. Quello che quell’articolo non copre è una modalità di fallimento che emerge solo quando il compiler incontra un albero di componenti reale: può decidere che due componenti nello stesso flusso di dati meritano trattamenti opposti, e quel disaccordo produce una UI che resta ferma esattamente in un punto mentre tutto il resto si aggiorna correttamente.

Il sintomo

Un widget tipo griglia dati: una tabella grande più un footer piccolo con conteggio righe e controlli di paginazione, entrambi alimentati dallo stesso stato. Filtri o ordini la tabella, e le righe si aggiornano subito e correttamente — ma il footer continua a mostrare il vecchio conteggio e la vecchia pagina, come se non si fosse mai ri-renderizzato. Ricaricare la pagina risolve. Rifattorizzare il footer per ricevere gli stessi valori via prop (invece di leggerli dal context) risolve anche quello. Nessuno dei due fatti punta a una spiegazione vera e propria.

Conferma che sia il compiler

Prima di costruire una riproduzione minima, fai il test più economico che hai: rimuovi temporaneamente il plugin React Compiler dalla configurazione di build (o escludi da esso i file coinvolti) e ricarica. Se il footer torna ad aggiornarsi correttamente, hai isolato la causa nella memoizzazione del compiler — non nella libreria di stato, non in una closure vecchia, non in una dipendenza mancante da qualche parte. Quel singolo toggle trasforma “da qualche parte in questa feature” in “da qualche parte in quello che il compiler ha deciso di memoizzare”, che è già la maggior parte del lavoro diagnostico, in pochi secondi.

Una riproduzione minima

Riduci lo scenario all’essenziale: un hook che incapsula un oggetto stateful mutato sul posto invece che sostituito, e due componenti che lo consumano — uno direttamente, l’altro tramite context.

// Pattern comune negli hook stateful di librerie di terze parti: l'identità
// dell'oggetto restituito resta stabile tra i render; cambia solo il suo interno.
function useDataTableState(rows) {
  const table = useRef(null);
  if (table.current === null) {
    table.current = createTableInstance(rows);
  }
  table.current.setRows(rows); // muta sul posto, stesso riferimento restituito
  return table.current;
}

function DataTable({ rows }) {
  const table = useDataTableState(rows);
  return (
    <TableContext.Provider value={table}>
      <table>{/* renderizza table.getVisibleRows() */}</table>
      <TableFooter />
    </TableContext.Provider>
  );
}

function TableFooter() {
  const table = useContext(TableContext);
  return <footer>{table.getRowCount()} righe · pagina {table.getState().pageIndex}</footer>;
}

DataTable si renderizza correttamente dopo ogni aggiornamento. TableFooter resta congelato sui primi valori che ha mai visto.

Causa radice

Il React Compiler analizza staticamente ogni componente e decide, funzione per funzione, se è sicuro avvolgerne l’output nell’equivalente memoizzato di useMemo. Quando non riesce a dimostrare che il valore restituito da un hook si comporta come stato immutabile e a riferimento stabile — esattamente il caso di useDataTableState, che muta e restituisce lo stesso oggetto — il compiler esce dalla memoizzazione e lascia quel componente non memoizzato. È la scelta corretta, prudente: meglio ri-renderizzare ogni volta che memoizzare contro un valore che mente sull’essere cambiato.

Il problema è che questa uscita non si propaga attraverso il context. TableFooter non chiama useDataTableState — chiama solo useContext, un’operazione di cui il compiler non ha motivo di diffidare. Dal suo punto di vista, TableFooter è un componente ordinario con una dipendenza ordinaria, quindi lo memoizza volentieri. E dato che table è lo stesso riferimento all’oggetto tra un render e l’altro (solo il suo stato interno è mutato), il TableFooter memoizzato vede “niente è cambiato” e restituisce il suo output in cache — anche se table.getRowCount() restituirebbe qualcosa di diverso se venisse effettivamente chiamato.

Il risultato: due componenti alimentati dallo stesso stato sottostante, uno correttamente escluso dalla memoizzazione, l’altro memoizzato per errore, perché il rilevamento dei rischi del compiler guarda solo a come un componente ottiene i suoi dati — una chiamata diretta a un hook — non a cosa quei dati effettivamente sono.

⚠ Attenzione

È facile non accorgersene perché non appare nessun errore e nessuna regola di lint scatta di default: useContext è una chiamata perfettamente normale e sicura per il compiler. Il rischio vive un livello più su, nell’hook che ha prodotto il valore che ora scorre nel context, e il compiler non può vedere così lontano.

Il fix

Escludi esplicitamente il consumer dalla memoizzazione del compiler, con la stessa direttiva che il compiler applica già internamente quando esce da solo:

function TableFooter() {
  'use no memo';
  const table = useContext(TableContext);
  return <footer>{table.getRowCount()} righe · pagina {table.getState().pageIndex}</footer>;
}

Tutto qui. 'use no memo' dice al compiler “non toccare questo componente”, ripristinando il comportamento di ri-render a ogni aggiornamento del genitore che era implicitamente corretto prima ancora che il compiler entrasse in gioco.

La regola pratica

Ogni componente che legge — direttamente o tramite context — un valore prodotto da un hook che il compiler ha escluso dalla memoizzazione ha bisogno della stessa esclusione, e va aggiunta a mano: il compiler non traccia il rischio attraverso un confine di context o una prop. In pratica:

  • Tratta “incompatibile con il compiler” come una proprietà del valore, non solo del componente che lo produce per primo. Se un hook restituisce qualcosa con una forma mutabile a riferimento stabile (comune in librerie di tabelle/griglie, alcune state machine, tutto ciò che sotto usa useRef), ogni consumer a valle — diretto o via context — è un candidato allo stesso 'use no memo'.
  • Non fidarti dell’assenza di un warning del compiler come prova che un componente sia sicuro da memoizzare. Il warning scatta dove il rischio viene creato, non ovunque il valore rischioso venga letto.
  • Se controlli tu l’hook, il fix più duraturo è renderlo compiler-friendly invece di escludere i componenti uno per uno: restituisci un nuovo oggetto (o incrementa un contatore di versione accanto ad esso) ogni volta che i dati cambiano davvero, così l’uguaglianza referenziale significa quello che il compiler assume che significhi.

💡 Consiglio

Per intercettare questa classe di bug prima che vada in produzione, attiva “Highlight updates when components render” in React DevTools mentre usi la funzionalità. Un componente che dovrebbe aggiornarsi e non lampeggia è un segnale molto più veloce che rincorrere valori vecchi con un debugger.

Condividi:XLinkedInFacebookWhatsApp
Diego Betto

Scritto da

Diego Betto

Co-Fondatore & CTO presso PAPION. Senior full-stack engineer specializzato in React, TypeScript, Node.js e sicurezza applicativa.