React Callback Refs: O que são e como usá-los

Durante o desenvolvimento, frequentemente precisamos interagir diretamente com os elementos do DOM. Nesses casos, o React nos fornece um mecanismo chamado refs, que permite acesso aos elementos depois de serem renderizados. Comumente, usamos refs de objeto padrão via useRef (vamos chamá-los assim), mas há outra abordagem conhecida como callback refs. Este método oferece flexibilidade e controle adicionais sobre o ciclo de vida dos elementos, nos permitindo realizar ações específicas em momentos precisos quando os elementos são anexados ou desanexados do DOM.

Neste artigo, quero explicar o que são os callback refs e como eles funcionam, discutir os problemas que você pode encontrar e mostrar exemplos de seu uso.

O que São Callback Refs e Como Eles Funcionam?

Callback refs oferecem a você um controle mais granular sobre a anexação de ref em comparação com refs de objeto. Vamos ver como eles funcionam na prática:

  1. Montagem. Quando um elemento é montado no DOM, React chama a função de ref com o próprio elemento DOM. Isso permite que você execute ações com o elemento imediatamente após ele aparecer na página.
  2. Desmontagem. Quando um elemento é desmontado, o React chama a função de ref com null. Isso lhe dá a oportunidade de limpar ou cancelar quaisquer ações associadas a esse elemento.

Exemplo: Rastreando Montagem e Desmontagem

TypeScript

 

import React, { useCallback, useState } from 'react';

function MountUnmountTracker() {
  const [isVisible, setIsVisible] = useState(false);

  const handleRef = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      console.log('Element mounted:', node);
    } else {
      console.log('Element unmounted');
    }
  }, []);

  return (
    <div>
      <button onClick={() => setIsVisible((prev) => !prev)}>
        {isVisible ? 'Hide' : 'Show'} element
      </button>
      {isVisible && <div ref={handleRef}>Tracked element</div>}
    </div>
  );
}

export default MountUnmountTracker;

Cada vez que alternamos a visibilidade do elemento, a função handleRef é chamada com o nó ou null, permitindo-nos rastrear o momento em que o elemento é anexado ou removido.

Problemas Comuns e Soluções

Problema: Invocações Repetidas do Callback Ref

Um problema frequente ao usar callback refs é a criação repetida da função de ref em cada re-renderização do componente. Por causa disso, o React pensa que é um novo ref, chama o antigo com null (limpando-o) e então inicializa o novo — mesmo que nosso elemento ou componente não tenha realmente mudado. Isso pode levar a efeitos colaterais indesejados.

Exemplo do Problema

Considere um componente Básico que tem um botão para alternar a visibilidade de um div com um callback ref, além de outro botão para forçar uma re-renderização do componente:

TypeScript

 

import React, { useState, useReducer } from 'react';

function Basic() {
  const [showDiv, setShowDiv] = useState(false);
  const [, forceRerender] = useReducer((v) => v + 1, 0);

  const toggleDiv = () => setShowDiv((prev) => !prev);

  const refCallback = (node: HTMLDivElement | null) => {
    console.log('div', node);
  };

  return (
    <div>
      <button onClick={toggleDiv}>Toggle Div</button>
      <button onClick={forceRerender}>Rerender</button>
      {showDiv && <div ref={refCallback}>Example div</div>}
    </div>
  );
}

export default Basic;

Cada vez que você clica no botão Rerender, o componente é re-renderizado, criando uma nova função refCallback. Como resultado, o React chama o antigo refCallback(null) e então o novo refCallback(nó), mesmo que nosso elemento com o ref não tenha mudado. No console, você verá div null e então div [nó] sucessivamente. Obviamente, geralmente queremos evitar chamadas desnecessárias como essas.

Solução: Memorizando o Callback Ref Com useCallback

Evitar isso é bastante simples: basta usar useCallback para memorizar a função. Dessa forma, a função permanece inalterada através das re-renderizações, a menos que suas dependências mudem.

TypeScript

 

import React, { useState, useCallback, useReducer } from 'react';

function Basic() {
  const [showDiv, setShowDiv] = useState(false);
  const [, forceRerender] = useReducer((v) => v + 1, 0);

  const toggleDiv = () => setShowDiv((prev) => !prev);

  const refCallback = useCallback((node: HTMLDivElement | null) => {
    console.log('div', node);
  }, []);

  return (
    <div>
      <button onClick={toggleDiv}>Toggle Div</button>
      <button onClick={forceRerender}>Rerender</button>
      {showDiv && <div ref={refCallback}>Example div</div>}
    </div>
  );
}

export default Basic;

Agora, refCallback é criado apenas uma vez, na renderização inicial. Não irá desencadear chamadas extras em renderizações posteriores, evitando callbacks desnecessários e melhorando o desempenho.

A Ordem dos Callback Refs, useLayoutEffect e useEffect

Antes de falarmos sobre como usar callback refs em seu código para resolver problemas específicos, vamos entender como os callback refs interagem com os hooks useEffect e useLayoutEffect para que você possa organizar adequadamente a inicialização e limpeza de recursos.

Ordem de Execução

  1. callback ref – chamado imediatamente após renderizar os elementos DOM, antes dos hooks de efeito serem executados
  2. useLayoutEffect – é executado após todas as mutações no DOM, mas antes do navegador pintar
  3. useEffect – é executado após o componente ter terminado de renderizar na tela
TypeScript

 

import React, { useEffect, useLayoutEffect, useCallback } from 'react';

function WhenCalled() {
  const refCallback = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      console.log('Callback ref called for div:', node);
    } else {
      console.log('Callback ref detached div');
    }
  }, []);

  useLayoutEffect(() => {
    console.log('useLayoutEffect called');
  }, []);

  useEffect(() => {
    console.log('useEffect called');
  }, []);

  return (
    <div>
      <div ref={refCallback}>Element to watch</div>
    </div>
  );
}

export default WhenCalled;

Saída no Console

  1. Callback ref chamado para div: [elemento div]
  2. useLayoutEffect chamado
  3. useEffect chamado

Esta sequência nos diz que os callback refs são acionados antes dos hooks como useLayoutEffect e useEffect, o que é essencial ter em mente ao escrever sua lógica.

Quais Problemas os Callback Refs Resolvem no Código?

Primeiro, vamos reproduzir um problema geralmente encontrado com refs de objeto regulares para então resolvê-lo com callback refs.

TypeScript

 

import { useCallback, useEffect, useRef, useState } from 'react';

interface ResizeObserverOptions {
  elemRef: React.RefObject<HTMLElement>;
  onResize: ResizeObserverCallback;
}

function useResizeObserver({ elemRef, onResize }: ResizeObserverOptions) {
  useEffect(() => {
    const element = elemRef.current;

    if (!element) {
      return;
    }

    const resizeObserver = new ResizeObserver(onResize);

    resizeObserver.observe(element);

    return () => {
      resizeObserver.unobserve(element);
    };
  }, [onResize, elemRef]);
}

export function UsageDom() {
  const [bool, setBool] = useState(false);
  const elemRef = useRef<HTMLDivElement>(null);

  const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
    console.log('resize', entries);
  }, []);

  useResizeObserver({ elemRef, onResize: handleResize });

  const renderTestText = () => {
    if (bool) {
      return <p ref={elemRef}>Test text</p>;
    }

    return <div ref={elemRef}>Test div</div>;
  };

  return (
    <div style={{ width: '100%', textAlign: 'center' }}>
      <button onClick={() => setBool((v) => !v)}>Toggle</button>
      {renderTestText()}
    </div>
  );
}

Não vamos nos aprofundar em cada detalhe aqui. Em resumo, estamos monitorando o tamanho do nosso elemento div ou p através de um ResizeObserver. Inicialmente, tudo funciona bem: ao montar, conseguimos obter o tamanho do elemento, e os redimensionamentos também são relatados no console.

O verdadeiro problema começa quando alternamos o estado para mudar o elemento que estamos observando. Quando mudamos o estado e, portanto, substituímos o elemento monitorado, nosso ResizeObserver não funciona mais corretamente. Ele continua observando o primeiro elemento, que já foi removido do DOM! Mesmo alternar de volta para o elemento original não ajuda porque a assinatura do novo elemento nunca se conecta corretamente.

Nota: A solução a seguir é mais representativa do que você poderia escrever em um contexto de biblioteca, precisando de código universal. Em um projeto real, você poderia resolver isso através de uma combinação de flags, efeitos, etc. Mas em código de biblioteca, você não tem conhecimento do componente específico ou do seu estado. Este é exatamente o cenário onde referências de callback podem nos ajudar.

TypeScript

 

import { useCallback, useRef, useState } from 'react';

function useResizeObserver(onResize: ResizeObserverCallback) {
  const roRef = useRef<ResizeObserver | null>(null);

  const attachResizeObserver = useCallback(
    (element: HTMLElement) => {
      const resizeObserver = new ResizeObserver(onResize);
      resizeObserver.observe(element);
      roRef.current = resizeObserver;
    },
    [onResize]
  );

  const detachResizeObserver = useCallback(() => {
    roRef.current?.disconnect();
  }, []);

  const refCb = useCallback(
    (element: HTMLElement | null) => {
      if (element) {
        attachResizeObserver(element);
      } else {
        detachResizeObserver();
      }
    },
    [attachResizeObserver, detachResizeObserver]
  );

  return refCb;
}

export default function App() {
  const [bool, setBool] = useState(false);

  const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
    console.log('resize', entries);
  }, []);

  const resizeRef = useResizeObserver(handleResize);

  const renderTestText = () => {
    if (bool) {
      return <p ref={resizeRef}>Test text</p>;
    }

    return <div ref={resizeRef}>Test div</div>;
  };

  return (
    <div style={{ width: '100%', textAlign: 'center' }}>
      <button onClick={() => setBool((v) => !v)}>Toggle</button>
      {renderTestText()}
    </div>
  );
}

Como você pode ver, reescrevemos nosso hook useResizeObserver para usar uma referência de callback. Nós apenas passamos uma callback (memorizada) que deve ser acionada no redimensionamento, e não importa quantas vezes alternamos elementos, nosso callback de redimensionamento ainda funciona. Isso acontece porque o observador se conecta a novos elementos e se desconecta de antigos exatamente no momento que queremos, graças à referência de callback.

O principal benefício aqui é que o desenvolvedor que usa nosso gancho não precisa mais se preocupar com a lógica de adicionar/remover observadores nos bastidores – encapsulamos essa lógica dentro do gancho. O desenvolvedor só precisa passar um retorno de chamada para o nosso gancho e anexar sua referência retornada aos elementos.

Combinando Múltiplas Refs em Uma

Aqui está outro cenário onde as refs de retorno de chamada entram em ação:

TypeScript

 

import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';

interface InputProps {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = forwardRef(function Input(
  props: InputProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (!inputRef.current) {
      return;
    }

    console.log(inputRef.current.getBoundingClientRect());
  }, []);

  return <input {...props} ref={ref} />;
});

export function UsageWithoutCombine() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const focus = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input ref={inputRef} />
      <button onClick={focus}>Focus</button>
    </div>
  );
}

No código acima, temos um componente de input simples no qual definimos uma ref, pegando-a das props usando forwardRef. Mas como usamos inputRef dentro do componente Input se também precisamos da ref de fora para algo como focar? Talvez queiramos fazer algo mais no próprio componente de input, como getBoundingClientRect. Substituir a ref da propriedade pela nossa ref interna significa que o foco de fora não funcionará mais. Então, como combinamos essas duas refs?

Aqui é onde as refs de retorno de chamada ajudam novamente:

TypeScript

 

import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';

type RefItem<T> =
  | ((element: T | null) => void)
  | React.MutableRefObject<T | null>
  | null
  | undefined;

function useCombinedRef<T>(...refs: RefItem<T>[]) {
  const refCb = useCallback((element: T | null) => {
    refs.forEach((ref) => {
      if (!ref) {
        return;
      }

      if (typeof ref === 'function') {
        ref(element);
      } else {
        ref.current = element;
      }
    });
  }, refs);

  return refCb;
}

interface InputProps {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = forwardRef(function Input(
  props: InputProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const inputRef = useRef<HTMLInputElement>(null);
  const combinedInputRef = useCombinedRef(ref, inputRef);

  useEffect(() => {
    if (!inputRef.current) {
      return;
    }

    console.log(inputRef.current.getBoundingClientRect());
  }, []);

  return <input {...props} ref={combinedInputRef} />;
});

export function UsageWithCombine() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const focus = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input ref={inputRef} />
      <button onClick={focus}>Focus</button>
    </div>
  );
}

Explicação

Implementamos um gancho useCombinedRef onde um desenvolvedor pode passar refs padrão, refs de retorno de chamada, e opcionalmente null ou undefined. O gancho em si é apenas um useCallback que percorre o array de refs. Se o argumento for null, o ignoramos, mas se a ref for uma função, a chamamos com o elemento; se for um objeto de ref padrão, definimos ref.current para o elemento.

Dessa forma, mesclamos múltiplas referências em uma só. No exemplo acima, tanto getBoundingClientRect dentro do componente Input quanto a chamada de foco externa funcionarão corretamente.

O que Mudou no React 19 em Relação às Referências de Callback?

Limpeza Automática

O React agora lida automaticamente com a limpeza de referências de callback quando os elementos são desmontados, tornando o gerenciamento de recursos mais simples. Aqui está um exemplo que a equipe do React mostra em sua documentação:

TypeScript

 

 {
    // referência criada

    // NOVO: retorne uma função de limpeza para redefinir
    // a referência quando o elemento é removido do DOM.
    return () => {
      // limpeza da referência
    };
  }}
/>

Você pode ler mais detalhes sobre isso no post oficial do blog, onde é mencionado que em breve, limpar referências via null pode ser descontinuado, deixando uma única maneira padronizada de limpar referências.

Escolhendo Entre Referências Normais e Referências de Callback

  • Use referências padrão (useRef) quando você só precisar de acesso simples a um elemento DOM ou quiser preservar algum valor entre renderizações sem ações adicionais ao anexar ou desanexar.
  • Use referências de callback quando você precisar de um controle mais granular sobre o ciclo de vida do elemento, quando estiver escrevendo código universal (por exemplo, na sua própria biblioteca ou pacote), ou quando precisar gerenciar múltiplas referências juntas.

Conclusão

Referências de callback no React são uma ferramenta poderosa que oferece aos desenvolvedores flexibilidade e controle adicionais ao trabalhar com elementos DOM. Na maioria dos casos, referências de objeto padrão via useRef são suficientes para tarefas do dia a dia, mas referências de callback podem ajudar em cenários mais complexos, como os que discutimos acima.

Source:
https://dzone.com/articles/react-callback-refs-guide