Um remédio pro useEffect
O useEffect nunca foi uma ferramenta de reatividade genérica, mas de sincronização com sistemas externos. O useEffectEvent, enquanto auxiliar, resolve um problema específico — mas não redefine o papel do useEffect.
A versão 19.2 do React finalmente lançou o useEffectEvent. A proposta é que o novo hook melhore o useEffect e, portanto, é muito bem vinda. No entanto, é importante entender exatamente o que ele resolve para não repetirmos equívocos. Na época que lançaram o useEffect, muitos apontaram que era uma API problemática, pois uma boa API precisa ter sua intenção clara para aquele que a consome. Esse não era o caso.
Entre todos os problemas que se apresentaram, acredito que a confusão que causou o estrago maior estava num nível mais alto da abstração. Veio do equívoco de achar que o effect servia para implementar qualquer reatividade de maneira declarativa — nos primórdios do React, a documentação o vendia como sendo uma biblioteca de UI declarativa — e as pessoas não hesitaram em generalizar conceitos que deveriam ser aplicados com observância às nuances de cada caso. Aplicou-se programação declarativa para resolver qualquer tipo de problema, mesmo quando se tratava de casos que requeriam soluções imperativas por sua própria natureza.

Muitos entenderam que o useEffect era uma ferramenta disponibilizada para isso. Assim, se eu tenho um estado “A” que deve mudar toda vez que muda o estado “B”, eu implemento essa lógica dentro de um effect e pronto. Parece óbvio, mas, a partir disso, pôs-se qualquer variação do tipo dentro desses hooks, mesmo quando não precisavam ou deveriam estar ali (deveriam estar em event handlers, por exemplo). O efeito disso foi uma infinidade de lógica de gestão de estado emaranhada e inconsistente, rerenderings desnecessários, etc..
Quando os problemas começaram a se apresentar, houve uma tentativa de esclarecer qual seria o modo correto de utilização com linting, documentação, posts. Dan Abramov, por exemplo explicou, no seu artigo A Complete Guide To useEffect, que:
…You should think of effects in a similar way.
useEffectlets you synchronize things outside of the React tree according to our props and state.
Isso apontou a comunidade na direção certa. Ou seja, o useEffect tem que ser utilizado quando tenho que sincronizar o estado interno da aplicação com o armazenamento local do browser, modificações de URL ou de tema, estado remoto (através de chamadas HTTP), etc. Todos esses, elementos que estão fora da árvore de componentes e da gestão interna de estado.
Além disso, ressaltou-se que os event handlers estavam sendo subutilizados. O mesmo Abramov explicou que se a mudança no estado parte da interação com o usuário, aquele comportamento deveria ser gerido dentro de um event handler, não dentro de um effect.
Na mesma época, foi publicado na documentação oficial o artigo You Might Not Need an Effect que ratifica esses pontos.
Okay, mas ainda restou um problema
Entendemos quando e como deveríamos usar o hook. No entanto, restava um defeito de implementação: todos os estados utilizados no effect, chamadas nesse escopo de “dependências”, deveriam ser declaradas no array de dependências. Toda vez que uma dependência variasse o seu valor (ou referência, se não fosse uma primitiva), o effect seria executado, efetuando a sincronização devida. Porém, muito frequentemente, a sincronização que implementamos dentro do effect deve ser executada quando uma dependência muda, mas não necessariamente todas.
Consideremos, como exemplo, um componente para uma chat room onde usa-se um useEffect para abrir a conexão com um web socket. Na URL de conexão, é necessário que se declare o roomId. Uma vez aberta a conexão, declara-se um listener que acrescenta uma nova mensagem à lista sempre que ela chega.
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 <>...</>;
}
Agora digamos que, além de adicionar a nova mensagem, queremos também que o componente atualize o contador de mensagens não lidas se a nova mensagem chega enquanto a janela não está ativa. Para isso, adicionamos mais estados e atualizamos o 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]); // O array de dependências está incompleto!
... // Lógica que gere a variação de isWindowFocused aqui
return <>...</>;
}
A implementação do effect depende do estado isWindowFocused. Quando declara-se a implementação do onmessage listener, isWindowFocused carrega o valor corrente no momento que o effect é executado, que é true. Todas as vezes subsequentes em que onmessage será executado, o valor de isWindowFocused no interno da implementação do listener será sempre o mesmo, mesmo que o estado a nível de componente seja atualizado depois. Esse é o problema de stale closure. Para evitá-lo, a variável deveria ser declarada no array de dependências. Fazê-lo, porém, implicaria em executar o effect não só quando roomId muda de valor, mas também isWindowFocused. Toda vez que o usuário abrisse a janela do browser, a instância do web socket seria recriada, e o listener seria redefinido com o novo valor, o que não é o comportamento desejado.
Aqui entra o useEffectEvent
A ideia é que o useEffectEvent resolva precisamente esse tipo de problema. Mantém-se no array de dependências do useEffect somente os estados que realmente deveriam disparar o effect para sincronização e implementa-se no callback passado ao useEffectEvent toda a lógica que envolve os outros estados utilizados na sincronização mas que não devem dispará-la. O retorno desse hook é uma função, ou evento, com a mesma implementação passada como argumento e que é aplicada dentro do effect. Nota-se que dentro do effect, os estados que servem como gatilhos podem ser passados ao evento como argumentos.
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]);
... // Lógica que gere a variação de isWindowFocused aqui
return <>...</>;
}
O que faz o useEffectEvent por baixo dos panos
Acredito que seja válido olharmos um pouco dentro da implementação para entendermos por que isso funciona. Aplica-se aqui uma lógica de ponteiros para estabilizar a referência do evento e atualizar sua implementação, em todos os ciclos de rerendering. Assim, todos os estados utilizados dentro da implementação do evento serão sempre as mais recentes, resolvendo o stale closure e isolando os estados secundários (dependências que não devem ser gatilhos). Isso permite que o effect seja executado somente nas variações de estado desejadas.
O snippet abaixo, retirado do react-reconciler, mostra um dos primeiros níveis da implementação do useEffectEvent e evidencia o ponto onde a referência (ref) é retirada do estado memoizado. ref e o callback recebido como parâmetro são passados para o próximo nível no stack chamando 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);
};
}
Posteriormente, ref que aponta para a função exposta (impl) tem a implementação substituída pela versão mais recente (nextImpl), decorrente do rendering subsequente.
if (eventPayloads !== null) {
for (let ii = 0; ii < eventPayloads.length; ii++) {
const { ref, nextImpl } = eventPayloads[ii];
ref.impl = nextImpl;
}
}
Como muda o useEffect?
O useEffect utilizado com o useEffectEvent torna-se certamente uma ferramenta melhor, pois agora cobre uma variedade maior de possibilidades que vão ao encontro da proposta inicial do hook sem a necessidade de gambiarras para fazê-lo funcionar. Por exemplo, antes era possível estabilizar a referência de uma função usada dentro do effect usando um useRef e atualizando a implementação no .current “manualmente”. Agora temos uma solução oficial e mais polida para fazê-lo.
function Counter() {
const [count, setCount] = useState(0);
const [multiplier, setMultiplier] = useState(2);
// Armazena o callback numa ref
const handleTickRef = useRef();
// Sempre atualiza ref.current com a implementação fresca
handleTickRef.current = () => {
console.log(`Count: ${count}, Multiplier: ${multiplier}`);
setCount(count + multiplier);
};
useEffect(() => {
const intervalId = setInterval(() => {
// Invoca o callback usando a última versão da implementação
handleTickRef.current();
}, 1000);
return () => clearInterval(intervalId);
}, []); // Array de dependências pode ficar vazio e effect é executado somente quando o componente é montado
return (
<div>
<p>Count: {count}</p>
<p>Multiplier: {multiplier}</p>
<button onClick={() => setMultiplier(multiplier + 1)}>
Increase Multiplier
</button>
</div>
);
}
Importante porém que não perdamos de vista a intenção por trás da ferramenta. O paradigma não mudou. O useEffect continua sendo um hook desenhado para ser utilizado na sincronização do estado interno com o externo. Essa atualização é um refinamento, não é uma carta branca para que voltemos a usar o useEffect indiscriminadamente.