Ho fatto review di centinaia di pull request React in diversi team. Gli stessi errori si ripresentano continuamente: useMemo intorno a una concatenazione di stringhe, key={index} su una lista filtrabile, catene di useEffect che potrebbero essere un singolo valore derivato. Non sono errori da junior — anche i senior li fanno, perché il modello mentale di React è ingannevolmente semplice in superficie.
Questo articolo copre i meccanismi interni che contano nel lavoro quotidiano: come funziona il Fiber tree, cosa fa realmente la reconciliation, e i pattern degli hooks che la maggior parte degli sviluppatori sbaglia. Nessuna teoria fine a sé stessa — ogni sezione è una decisione che affronterai nel prossimo PR.
Il Fiber Tree: cosa costruisce React realmente
Quando scrivi JSX, non stai scrivendo HTML. Stai descrivendo un albero di oggetti JavaScript. React prende questa descrizione e costruisce un Fiber tree — una lista concatenata di «unità di lavoro» dove ogni nodo rappresenta un elemento o componente della tua UI.
Ogni nodo Fiber contiene puntatori al suo figlio, fratello e genitore. Contiene le props del componente, lo stato dei suoi hooks (come lista concatenata — ecco perché l'ordine degli hooks è importante) e un riferimento al nodo DOM reale che rappresenta. Punto cruciale: React mantiene due Fiber tree contemporaneamente: l'albero «corrente» mostrato a schermo, e un albero «work-in-progress» che viene costruito durante un render.
Perché è importante? Perché quando React «ri-renderizza», non tocca ancora il DOM. Costruisce un nuovo Fiber tree work-in-progress, lo confronta con l'albero corrente, calcola l'insieme minimo di modifiche DOM necessarie, e poi le applica in un unico batch. Questa è la reconciliation.
// React doesn't work with the DOM directly.
// It maintains a Fiber tree — a linked list of "work units"
// Each Fiber node looks roughly like this:
interface FiberNode {
type: 'div' | 'span' | typeof MyComponent; // what to render
key: string | null;
props: Record<string, unknown>;
stateNode: HTMLElement | null; // the actual DOM node (or component instance)
child: FiberNode | null; // first child
sibling: FiberNode | null; // next sibling
return: FiberNode | null; // parent
alternate: FiberNode | null; // the "other" tree (current vs work-in-progress)
effectTag: 'PLACEMENT' | 'UPDATE' | 'DELETION';
memoizedState: unknown; // hooks live here, as a linked list
}
// When you write JSX, React builds this tree — NOT a DOM tree.
// <App> Fiber: App
// <Header /> Fiber: Header (child of App)
// <main> Fiber: main (sibling of Header)
// <Article /> Fiber: Article (child of main)
// </main>
// </App>
// Each Fiber holds its own hooks state (memoizedState),
// a pointer to its DOM node, and links to parent/child/sibling.Reconciliation: come React decide cosa cambia
La reconciliation è l'algoritmo di diffing di React. Percorre entrambi i Fiber tree (corrente e work-in-progress) e applica tre regole per decidere cosa fare con ogni nodo.
Regola 1: se il tipo di elemento è cambiato (div in span, ComponentA in ComponentB), React distrugge l'intero sottoalbero e lo ricostruisce da zero. Tutto lo stato viene perso. Tutti i figli vengono smontati e rimontati. Ecco perché wrappare un componente in un condizionale che alterna tra due tipi diversi è costoso — non è un aggiornamento, è una distruzione e ricostruzione completa.
Regola 2: se il tipo di elemento è lo stesso, React mantiene l'istanza in vita e aggiorna solo le props cambiate. Questo è il percorso veloce. Un pulsante che cambia className da "blue" a "red" è un singolo aggiornamento di attributo DOM.
Regola 3: per le liste di figli, React usa le keys per far corrispondere gli elementi tra il vecchio e il nuovo albero. Senza keys, fa corrispondere per indice — il che significa che aggiungere un elemento in testa fa «aggiornare» ogni elemento esistente con i dati sbagliati. Con keys stabili, React identifica quali elementi si sono spostati, quali sono stati aggiunti e rimossi, e applica l'insieme minimo di operazioni.
// Reconciliation = React's diffing algorithm
// It compares the OLD Fiber tree with the NEW one, node by node.
// Rule 1: Different type? Tear down and rebuild.
// Old: <div><Counter /></div>
// New: <span><Counter /></span>
// Result: entire <div> subtree destroyed, <span> subtree created from scratch.
// Counter loses ALL state — even if the component is identical.
// Rule 2: Same type? Update props, keep the instance.
// Old: <button className="blue" onClick={handleA}>Save</button>
// New: <button className="red" onClick={handleB}>Save</button>
// Result: React updates className and onClick on the existing DOM node.
// No unmount, no remount. Fast.
// Rule 3: Lists need keys for identity.
// Without keys, React matches by index:
// Old: [Alice, Bob, Charlie]
// New: [Zara, Alice, Bob, Charlie] // prepended
// React thinks: Alice→Zara (update), Bob→Alice (update), Charlie→Bob (update), +Charlie (insert)
// That's 4 operations instead of 1 insert.
// With keys:
// Old: [Alice(1), Bob(2), Charlie(3)]
// New: [Zara(0), Alice(1), Bob(2), Charlie(3)]
// React matches by key: Alice, Bob, Charlie are unchanged. Just insert Zara.
// 1 operation. That's why keys exist.Le Keys: molto più di un «identificatore unico»
Tutti sanno di usare key={item.id} sulle liste. Ma le keys hanno uno scopo più profondo che la maggior parte degli sviluppatori non vede: controllano l'identità del componente nel Fiber tree.
Quando una key cambia, React la tratta come un componente completamente diverso — anche se il tipo è lo stesso. La vecchia istanza viene smontata (tutto lo stato resettato), e una nuova viene montata. Questo è incredibilmente utile per resettare lo stato di un componente senza hack con useEffect.
L'esempio classico: un form che deve resettarsi quando l'utente passa a un altro record. Invece di un useEffect che osserva l'ID e resetta manualmente lo stato (fragile e crea stati intermedi), basta keyare il form sull'ID del record. Quando l'ID cambia, React fa un unmount/remount pulito. Tutti i chiamate useState ritornano i valori iniziali. Tutti i cleanup degli useEffect vengono eseguiti. Un nuovo inizio completo, senza codice nel componente.
// DON'T: key={index} on a list that reorders, filters, or prepends
// This destroys the performance benefit AND causes state bugs
{items.map((item, i) => (
<TodoItem key={i} item={item} /> // index shifts when items move
))}
// DO: key={item.id} — a stable identity that follows the data
{items.map((item) => (
<TodoItem key={item.id} item={item} />
))}
// TRICK: Use key to FORCE a full remount when you want to reset state
// This is an intentional, legitimate use of reconciliation:
function EditForm({ userId }: { userId: string }) {
// Changing userId doesn't reset internal form state...
const [name, setName] = useState('');
// ...because React sees the same component type at the same position
}
// Fix: key the component on the identity that should trigger a reset
<EditForm key={userId} userId={userId} />
// Now when userId changes, React unmounts the old EditForm
// and mounts a fresh one — all state resets automatically.
// No useEffect cleanup, no stale state bugs.useMemo: quando aiuta davvero (e quando è spreco)
useMemo è l'hook più abusato in React. Ho visto codebase dove ogni valore calcolato è wrappato in useMemo «per la performance». È controproducente — useMemo ha un costo. Memorizza il risultato precedente e le dipendenze, confronta le dipendenze a ogni render e restituisce il valore in cache se nulla è cambiato. Per calcoli economici, il confronto costa più del ricalcolo.
La regola è semplice: useMemo è per calcoli costosi (ordinare grandi array, trasformazioni di dati complesse, parsing) o per stabilizzare i riferimenti passati a figli React.memo. La seconda parte è critica — useMemo senza React.memo sul componente ricevente è quasi sempre inutile, perché il figlio ri-renderizza comunque quando il genitore ri-renderizza.
Pensate a useMemo + React.memo come una coppia. useMemo stabilizza il riferimento. React.memo sul figlio salta il ri-render quando le props non sono cambiate. Rimuovete uno dei due e l'ottimizzazione non funziona.
// DON'T: useMemo everything "just in case"
// This is SLOWER than no memo for cheap operations:
const fullName = useMemo(() => first + ' ' + last, [first, last]);
// The comparison of [first, last] costs more than the concatenation.
// DON'T: useMemo on object literals passed to children
// unless the child is wrapped in React.memo
const style = useMemo(() => ({ color: 'red' }), []);
// If <Child> isn't React.memo'd, it re-renders anyway when its parent does.
// The stable reference achieves nothing.
// DO: useMemo for genuinely expensive computations
const sortedItems = useMemo(
() => items.toSorted((a, b) => a.score - b.score),
[items]
);
// Sorting 10,000 items on every render is real waste.
// DO: useMemo + React.memo as a pair
const MemoizedChild = React.memo(ExpensiveChart);
function Dashboard({ data }: { data: DataPoint[] }) {
const processed = useMemo(() => transformData(data), [data]);
// useMemo keeps the reference stable
// React.memo on the child skips re-render if props are the same
return <MemoizedChild data={processed} />;
// BOTH pieces are needed. useMemo alone does nothing visible.
// React.memo alone doesn't help if the parent creates new objects each render.
}useCallback: le stesse regole valgono
useCallback è letteralmente useMemo(() => fn, deps) sotto il cofano. Le stesse regole valgono: è utile solo quando il riferimento stabile previene lavoro a valle — il che significa che il ricevente deve essere wrappato in React.memo, o la funzione è usata in un array di dipendenze useEffect.
Impongo una regola di team semplice: niente useCallback a meno che non si possa indicare il React.memo o useEffect che ne beneficia. Se non si può, si sta aggiungendo complessità per zero guadagno di performance.
// useCallback has the SAME rules as useMemo — it's literally
// useMemo(() => fn, deps) under the hood.
// DON'T: wrap every handler in useCallback
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
// Unless <Child> is React.memo, this is pure overhead.
// DO: useCallback when passing to a memoized child
const MemoButton = React.memo(Button);
function Toolbar({ onSave }: { onSave: () => void }) {
// If the parent re-renders, a new arrow function is created each time
// React.memo on Button would see a new prop and re-render anyway
// useCallback keeps the same function reference across renders
const handleSave = useCallback(() => onSave(), [onSave]);
return <MemoButton onClick={handleSave}>Save</MemoButton>;
}
// DO: useCallback for functions used in useEffect dependencies
function useAutoSave(data: Data) {
const save = useCallback(() => {
api.save(data);
}, [data]);
useEffect(() => {
const id = setInterval(save, 5000);
return () => clearInterval(id);
}, [save]); // stable reference prevents re-creating the interval
}Inizializzazione pigra dello stato: il fix in una riga
useState accetta sia un valore che una funzione. Quando passi una funzione, React la chiama solo al montaggio iniziale — non a ogni render. Questo conta quando il valore iniziale è costoso da calcolare: lettura da localStorage, parsing JSON, creazione di istanze di classi.
Vedo questo errore costantemente: useState(JSON.parse(localStorage.getItem(key)!)). Quel parse viene eseguito a ogni render, ma solo il primo risultato viene usato. Il fix è un carattere: useState(() => JSON.parse(localStorage.getItem(key)!)). L'arrow function lo rende pigro.
// DON'T: expensive computation runs on EVERY render
const [state, setState] = useState(parseJsonFromStorage(key));
// parseJsonFromStorage runs every render, but only the first result is used.
// DO: pass a function — React calls it only on mount
const [state, setState] = useState(() => parseJsonFromStorage(key));
// The () => wrapper makes it lazy. Runs once.
// Same pattern with useRef — but useRef has no lazy initializer:
// DON'T:
const worker = useRef(new HeavyWorker()); // created every render, discarded
// DO:
const workerRef = useRef<HeavyWorker | null>(null);
function getWorker() {
if (!workerRef.current) workerRef.current = new HeavyWorker();
return workerRef.current;
}
// Lazy singleton pattern. Created on first access, not on every render.useRef oltre il DOM
useRef è sottoutilizzato. La maggior parte degli sviluppatori pensa che serva solo per accedere agli elementi DOM. Ma il suo vero potere è come contenitore mutabile che persiste tra i render senza scatenarne. Qualsiasi valore che deve sopravvivere ai render ma non dovrebbe causare un ri-render quando cambia appartiene a un ref.
I pattern sotto sono quelli che uso di più: tracciare valori precedenti, creare callback stabili che chiamano sempre l'ultima versione della funzione (risolve il problema delle closure stantie), e costruire interval/timeout che non devono essere ricreati quando il loro callback cambia.
// useRef is NOT just for DOM elements.
// It's a mutable container that survives re-renders without causing them.
// Track previous value (no re-render, no useEffect)
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>();
useEffect(() => { ref.current = value; });
return ref.current;
}
// Stable callback that always calls the latest version
// without re-triggering effects that depend on it
function useStableCallback<T extends (...args: never[]) => unknown>(fn: T): T {
const ref = useRef(fn);
ref.current = fn; // always up to date, no dependency array needed
return useCallback(
((...args) => ref.current(...args)) as T,
[] // genuinely stable — never changes
);
}
// Avoid stale closures in intervals/timeouts
function useInterval(callback: () => void, ms: number) {
const callbackRef = useRef(callback);
callbackRef.current = callback; // always fresh
useEffect(() => {
const id = setInterval(() => callbackRef.current(), ms);
return () => clearInterval(id);
}, [ms]); // only re-creates when interval changes, not when callback does
}Le regole che contano davvero
Dopo anni a guidare team React, queste sono le regole che impongo. Niente useMemo o useCallback a meno che non si possa nominare il React.memo o useEffect che ne beneficia. Usate key per controllare l'identità — resettate lo stato cambiando le keys, non con useEffect. Mai usare index come key su liste dinamiche. Passate funzioni a useState per valori iniziali costosi. Mettete i valori non-renderizzanti nei ref, non nello stato. Derivate i valori durante il render invece di sincronizzarli con useEffect.
La maggior parte dei problemi di performance React non si risolvono memorizzando di più — ma renderizzando di meno. Spostate lo stato dove viene usato. Dividete i componenti così che un cambio di stato nell'header non ri-renderizzi l'intera pagina. Usate le props children così i componenti wrapper non ri-renderizzano il loro contenuto. Questi cambiamenti strutturali battono qualsiasi quantità di useMemo.

