Un rimedio per useEffect
useEffect non è mai stato uno strumento di reattività generica, ma di sincronizzazione con sistemi esterni. useEffectEvent, come ausilio, risolve un problema specifico — ma non ridefinisce il ruolo di useEffect.
React 19.2 ha finalmente rilasciato useEffectEvent. L’idea è che il nuovo hook migliori useEffect e, quindi, è molto benvenuto. Tuttavia, è importante capire esattamente cosa risolve per non ripetere equivoci. All’epoca in cui hanno rilasciato useEffect, molti sostenevano che fosse un’API problematica, perché una buona API deve avere un’intenzione chiara per chi la consuma. Non era così.
Tra tutti i problemi emersi, credo che la confusione che ha causato il danno maggiore fosse a un livello di astrazione più alto. Derivava dall’equivoco di pensare che l’effect servisse a implementare qualsiasi reattività in modo dichiarativo — agli albori di React, la documentazione lo vendeva come una libreria di UI dichiarativa — e le persone non hanno esitato a generalizzare concetti che avrebbero dovuto essere applicati con attenzione alle sfumature di ogni caso. Si è applicata la programmazione dichiarativa per risolvere qualunque tipo di problema, anche quando si trattava di casi che richiedevano soluzioni imperative per loro stessa natura.

Molti hanno capito che useEffect fosse uno strumento messo a disposizione per questo. Così, se ho uno stato “A” che deve cambiare ogni volta che cambia lo stato “B”, implemento quella logica dentro un effect e basta. Sembra ovvio, ma da lì in poi si è messa qualsiasi variazione del tipo dentro questi hook, anche quando non serviva o non avrebbe dovuto esserci (dovevano essere in event handler, per esempio). L’effetto è stata un’infinità di logica di gestione dello stato aggrovigliata e incoerente, rerendering inutili, ecc.
Quando i problemi hanno iniziato a presentarsi, ci fu un tentativo di chiarire quale fosse il modo corretto di utilizzo con linting, documentazione, post. Dan Abramov, per esempio, spiegò nel suo articolo A Complete Guide To useEffect, che:
…You should think of effects in a similar way.
useEffectlets you synchronize things outside of the React tree according to our props and state.
Questo ha indirizzato la comunità nella direzione giusta. Cioè, useEffect va usato quando devo sincronizzare lo stato interno dell’applicazione con l’archiviazione locale del browser, modifiche di URL o di tema, stato remoto (attraverso chiamate HTTP), ecc. Tutti questi elementi sono fuori dall’albero dei componenti e dalla gestione interna dello stato.
Inoltre, è stato sottolineato che gli event handler erano sottoutilizzati. Lo stesso Abramov spiegò che se il cambiamento di stato parte dall’interazione con l’utente, quel comportamento dovrebbe essere gestito dentro un event handler, non dentro un effect.
Nello stesso periodo, fu pubblicato nella documentazione ufficiale l’articolo You Might Not Need an Effect che conferma questi punti.
Ok, ma restava ancora un problema
Abbiamo capito quando e come dovremmo usare l’hook. Tuttavia, restava un difetto di implementazione: tutti gli stati usati nell’effect, chiamati in questo contesto “dipendenze”, dovrebbero essere dichiarati nell’array delle dipendenze. Ogni volta che una dipendenza variava il suo valore (o la sua referenza, se non fosse una primitiva), l’effect veniva eseguito, effettuando la sincronizzazione dovuta. Però, molto frequentemente, la sincronizzazione che implementiamo dentro l’effect deve essere eseguita quando una dipendenza cambia, ma non necessariamente tutte.
Consideriamo, come esempio, un componente per una chat room dove si usa un useEffect per aprire la connessione con un web socket. Nella URL di connessione, è necessario dichiarare il roomId. Una volta aperta la connessione, si dichiara un listener che aggiunge un nuovo messaggio alla lista ogni volta che arriva.
function ChatRoom() {
const [messages, setMessages] = useState([]);
useEffect(() => {
const ws = new WebSocket(`wss://chat.com/${roomId}`);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages((prev) => [...prev, message]);
};
return () => ws.close();
}, [roomId]);
return <>...</>;
}
Ora immaginiamo che, oltre ad aggiungere il nuovo messaggio, vogliamo anche che il componente aggiorni il contatore dei messaggi non letti se il nuovo messaggio arriva mentre la finestra non è attiva. Per farlo, aggiungiamo più stati e aggiorniamo l’effect.
function ChatRoom() {
const [messages, setMessages] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isWindowFocused, setIsWindowFocused] = useState(true);
useEffect(() => {
const ws = new WebSocket(`wss://chat.com/${roomId}`);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages((prev) => [...prev, message]);
if (!isWindowFocused) {
setUnreadCount((prev) => prev + 1);
}
};
return () => ws.close();
}, [roomId]); // L’array delle dipendenze è incompleto!
... // Logica che gestisce la variazione di isWindowFocused qui
return <>...</>;
}
L’implementazione dell’effect dipende dallo stato isWindowFocused. Quando si dichiara l’implementazione del listener onmessage, isWindowFocused porta il valore corrente nel momento in cui l’effect viene eseguito, che è true. Tutte le volte successive in cui onmessage verrà eseguito, il valore di isWindowFocused all’interno dell’implementazione del listener sarà sempre lo stesso, anche se lo stato a livello di componente viene aggiornato dopo. Questo è il problema della stale closure. Per evitarlo, la variabile dovrebbe essere dichiarata nell’array delle dipendenze. Farlo, però, implicherebbe eseguire l’effect non solo quando roomId cambia valore, ma anche quando isWindowFocused cambia. Ogni volta che l’utente mette a fuoco la finestra del browser, l’istanza del web socket verrebbe ricreata e il listener verrebbe ridefinito con il nuovo valore, cosa che non è il comportamento desiderato.
Qui entra in gioco useEffectEvent
L’idea è che useEffectEvent risolva precisamente questo tipo di problema. Nell’array delle dipendenze di useEffect, si mantengono solo gli stati che dovrebbero effettivamente far scattare l’effect per la sincronizzazione e si implementa nel callback passato a useEffectEvent tutta la logica che coinvolge gli altri stati usati nella sincronizzazione ma che non devono farla scattare. Il ritorno di questo hook è una funzione, o evento, con la stessa implementazione passata come argomento e che viene applicata dentro l’effect. Si noti che dentro l’effect, gli stati che fungono da trigger possono essere passati all’evento come argomenti.
function ChatRoom() {
const [messages, setMessages] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isWindowFocused, setIsWindowFocused] = useState(true);
const incrementUnreadCount = useEffectEvent(() => {
if (!isWindowFocused) {
setUnreadCount((prev) => prev + 1);
}
})
useEffect(() => {
const ws = new WebSocket(`wss://chat.com/${roomId}`);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages((prev) => [...prev, message]);
incrementUnreadCount();
};
return () => ws.close();
}, [roomId]);
... // Logica che gestisce la variazione di isWindowFocused qui
return <>...</>;
}
Cosa fa useEffectEvent dietro le quinte
Credo che valga la pena guardare un po’ dentro l’implementazione per capire perché funziona. Si applica qui una logica di puntatori per stabilizzare la referenza dell’evento e aggiornare la sua implementazione in ogni ciclo di rerendering. Così, tutti gli stati usati dentro l’implementazione dell’evento saranno sempre i più recenti, risolvendo la stale closure e isolando gli stati secondari (dipendenze che non devono essere trigger). Questo permette che l’effect venga eseguito solo nelle variazioni di stato desiderate.
Lo snippet qui sotto, preso da react-reconciler, mostra uno dei primi livelli dell’implementazione di useEffectEvent ed evidenzia il punto in cui la referenza (ref) viene presa dallo stato memoizzato. ref e il callback ricevuto come parametro vengono passati al livello successivo nello stack chiamando useEffectEventImpl.
function updateEvent<Args, Return, F: (...Array<Args>) => Return>(
callback: F,
): F {
const hook = updateWorkInProgressHook();
const ref = hook.memoizedState;
useEffectEventImpl({ref, nextImpl: callback});
// $FlowIgnore[incompatible-return]
return function eventFn() {
if (isInvalidExecutionContextForEventFunction()) {
throw new Error(
"A function wrapped in useEffectEvent can't be called during rendering.",
);
}
return ref.impl.apply(undefined, arguments);
};
}
Successivamente, ref, che punta alla funzione esposta (impl), ha la sua implementazione sostituita dalla versione più recente (nextImpl), derivante dal rendering successivo.
if (eventPayloads !== null) {
for (let ii = 0; ii < eventPayloads.length; ii++) {
const { ref, nextImpl } = eventPayloads[ii];
ref.impl = nextImpl;
}
}
Come cambia useEffect?
Il useEffect usato con useEffectEvent diventa certamente uno strumento migliore, perché ora copre una varietà maggiore di possibilità che vanno incontro alla proposta iniziale dell’hook senza la necessità di “gambiarre” per farlo funzionare. Per esempio, prima era possibile stabilizzare la referenza di una funzione usata dentro l’effect usando un useRef e aggiornando l’implementazione in .current “manualmente”. Ora abbiamo una soluzione ufficiale e più rifinita per farlo.
function Counter() {
const [count, setCount] = useState(0);
const [multiplier, setMultiplier] = useState(2);
// Memorizza il callback in una ref
const handleTickRef = useRef();
// Aggiorna sempre ref.current con l’implementazione aggiornata
handleTickRef.current = () => {
console.log(`Count: ${count}, Multiplier: ${multiplier}`);
setCount(count + multiplier);
};
useEffect(() => {
const intervalId = setInterval(() => {
// Invoca il callback usando l’ultima versione dell’implementazione
handleTickRef.current();
}, 1000);
return () => clearInterval(intervalId);
}, []); // L’array delle dipendenze può rimanere vuoto e l’effect viene eseguito solo quando il componente viene montato
return (
<div>
<p>Count: {count}</p>
<p>Multiplier: {multiplier}</p>
<button onClick={() => setMultiplier(multiplier + 1)}>
Increase Multiplier
</button>
</div>
);
}
È importante, però, non perdere di vista l’intento dietro lo strumento. Il paradigma non è cambiato. useEffect continua a essere un hook progettato per essere usato nella sincronizzazione dello stato interno con quello esterno. Questo aggiornamento è un affinamento, non un lascia‑passare per tornare a usare useEffect indiscriminatamente.