Luca Mele
Luca Mele

React

React: cosa fare e non fare — Fiber, Reconciliation e gli Hooks che nessuno usa correttamente

React: cosa fare e non fare — Fiber, Reconciliation e gli Hooks che nessuno usa correttamente
Torna a tutti gli articoli
·9 min di lettura
Ascolta il podcast generato dall'IA
Tutti i podcast →

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.