Jarra,
Foto de perfil de Henrique de Almeida

por Henrique de Almeida,

Desenvolvedor de Software, da Planície Goytacá à Pianura Padana.

← Voltar para a página inicial

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.

A tweet from @DavidKPiano saying that declarative is code that you like, and imperative is code that you don't like

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