
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.
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.

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