Referencias de devolución de llamada en React: Qué son y cómo usarlas

Durante el desarrollo, a menudo necesitamos interacción directa con elementos del DOM. En tales casos, React nos proporciona un mecanismo llamado refs, que permite acceder a elementos después de que han sido renderizados. Comúnmente, utilizamos refs de objeto estándar a través de useRef (así los llamaremos), pero también existe otro enfoque conocido como refs de callback. Este método ofrece flexibilidad adicional y control sobre el ciclo de vida de los elementos, permitiéndonos realizar acciones específicas en momentos precisos cuando los elementos se adjuntan o se desvinculan del DOM.

En este artículo, quiero explicar qué son los refs de callback y cómo funcionan, discutir los problemas que podrías encontrar y mostrar ejemplos de su uso.

¿Qué son los refs de callback y cómo funcionan?

Los refs de callback te brindan un control más detallado sobre la vinculación de refs en comparación con los refs de objeto. Veamos cómo funcionan en la práctica:

  1. Montaje. Cuando un elemento se monta en el DOM, React llama a la función ref con el propio elemento del DOM. Esto te permite realizar acciones con el elemento inmediatamente después de que aparezca en la página.
  2. Desmontaje. Cuando un elemento se desmonta, React llama a la función ref con null. Esto te brinda la oportunidad de limpiar o cancelar cualquier acción asociada con ese elemento.

Ejemplo: Seguimiento de Montaje y Desmontaje

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 cambiamos la visibilidad del elemento, la función handleRef es llamada con el nodo o null, lo que nos permite rastrear el momento en que el elemento se adjunta o se desvincula.

Problemas Comunes y Soluciones

Problema: Invocaciones Repetidas de la Referencia de Devolución de Llamada

Un problema frecuente al usar referencias de devolución de llamada es la creación repetida de la función de referencia en cada re-renderizado del componente. Debido a esto, React piensa que es una nueva referencia, llama a la antigua con null (limpiándola) y luego inicializa la nueva, incluso si nuestro elemento o componente en realidad no ha cambiado. Esto puede llevar a efectos secundarios no deseados.

Ejemplo del Problema

Considera un componente Básico que tiene un botón para alternar la visibilidad de un div con una referencia de devolución de llamada, además de otro botón para forzar un re-renderizado del 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 haces clic en el botón Re-renderizar, el componente se vuelve a renderizar, creando una nueva función refCallback. Como resultado, React llama a la antigua refCallback(null) y luego a la nueva refCallback(node), aunque nuestro elemento con la referencia no ha cambiado. En la consola, verás div null y luego div [nodo] sucesivamente, repetidamente. Obviamente, generalmente queremos evitar llamadas innecesarias como esa.

Solución: Memoizar la Referencia de Devolución de Llamada con useCallback

Evitar esto es bastante sencillo: simplemente usa useCallback para memoizar la función. De esta manera, la función permanece sin cambios en los re-renderizados, a menos que cambien sus dependencias.

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;

Ahora, refCallback se crea solo una vez, en la renderización inicial. No desencadenará llamadas adicionales en renderizaciones posteriores, evitando llamadas innecesarias y mejorando el rendimiento.

El Orden de los Callback Refs, useLayoutEffect y useEffect

Antes de hablar sobre cómo utilizar los callback refs en tu código para resolver problemas específicos, comprendamos cómo interactúan los callback refs con los ganchos useEffect y useLayoutEffect para que puedas organizar adecuadamente la inicialización y limpieza de recursos.

Orden de Ejecución

  1. callback ref – se llama inmediatamente después de renderizar elementos del DOM, antes de que se ejecuten los ganchos de efecto
  2. useLayoutEffect – se ejecuta después de todas las mutaciones del DOM pero antes de que el navegador pinte
  3. useEffect – se ejecuta después de que el componente haya terminado de renderizarse en la pantalla
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;

Salida en la Consola

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

Esta secuencia nos indica que los callback refs se activan antes que los ganchos como useLayoutEffect y useEffect, lo cual es esencial tener en cuenta al escribir tu lógica.

¿Qué Problemas Resuelven los Callback Refs en el Código?

Primero, reproduzcamos un problema típicamente encontrado con refs de objetos regulares para luego resolverlo con 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>
  );
}

No nos adentraremos en cada detalle aquí. En resumen, estamos rastreando el tamaño de nuestro elemento div o p a través de un ResizeObserver. Inicialmente, todo funciona bien: al montar, podemos obtener el tamaño del elemento, y los redimensionamientos también se informan en la consola.

Los verdaderos problemas comienzan cuando cambiamos de estado para cambiar el elemento que estamos observando. Cuando cambiamos de estado y, por lo tanto, reemplazamos el elemento rastreado, nuestro ResizeObserver ya no funciona correctamente. ¡Sigue observando el primer elemento, que ya ha sido eliminado del DOM! Incluso volver al elemento original no ayuda porque la suscripción al nuevo elemento nunca se adjunta correctamente.

Nota: La siguiente solución es más representativa de lo que podrías escribir en un contexto de biblioteca, necesitando código universal. En un proyecto real, podrías resolverlo a través de una combinación de banderas, efectos, etc. Pero en el código de la biblioteca, no tienes conocimiento del componente específico o su estado. Este es precisamente el escenario donde las referencias de devolución de llamada pueden ayudarnos.

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 puedes ver, reescribimos nuestro gancho useResizeObserver para usar una referencia de devolución de llamada. Simplemente pasamos un callback (memoizado) que debería activarse en el redimensionamiento, y no importa cuántas veces alternemos entre elementos, nuestro callback de redimensionamiento sigue funcionando. Eso es porque el observador se adhiere a los nuevos elementos y se desvincula de los antiguos en el momento exacto que queremos, cortesía de la referencia de devolución de llamada.

El beneficio clave aquí es que el desarrollador que utiliza nuestro gancho ya no necesita preocuparse por la lógica de agregar/eliminar observadores bajo el capó, hemos encapsulado esa lógica dentro del gancho. El desarrollador solo necesita pasar una devolución de llamada a nuestro gancho y adjuntar su referencia devuelta a sus elementos.

Combinando Múltiples Referencias en Una

Aquí hay otro escenario donde las devoluciones de llamada de referencia vienen al rescate:

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>
  );
}

En el código anterior, tenemos un componente de entrada simple en el que establecemos una referencia, obteniéndola de las props usando forwardRef. Pero ¿cómo usamos inputRef dentro del componente Input si también necesitamos la referencia desde el exterior para algo como enfocar? Tal vez queremos hacer algo más en el propio componente de entrada, como getBoundingClientRect. Reemplazar la referencia de la prop por nuestra referencia interna significa que ya no funcionará el enfoque desde el exterior. Entonces, ¿cómo combinamos estas dos referencias?

Aquí es donde las devoluciones de llamada de referencia ayudan nuevamente:

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>
  );
}

Explicación

Hemos implementado un gancho useCombinedRef donde un desarrollador puede pasar referencias estándar, devoluciones de llamada de referencia y opcionalmente null o undefined. El gancho en sí es simplemente un useCallback que recorre la matriz de referencias. Si el argumento es null, lo ignoramos, pero si la referencia es una función, la llamamos con el elemento; si es un objeto de referencia estándar, establecemos ref.current en el elemento.

De esta manera, fusionamos múltiples refs en uno solo. En el ejemplo anterior, tanto getBoundingClientRect dentro del componente Input, como la llamada externa de enfoque funcionarán correctamente.

¿Qué cambió en React 19 con respecto a los Callback Refs?

Limpieza Automática

React ahora maneja automáticamente la limpieza de los callback refs al desmontar elementos, haciendo más simple la gestión de recursos. Aquí hay un ejemplo que el equipo de React muestra en su documentación:

TypeScript

 

 {
    // ref creado

    // NUEVO: devuelve una función de limpieza para restablecer
    // el ref cuando el elemento se elimina del DOM.
    return () => {
      // limpieza de ref
    };
  }}
/>

Puedes leer más detalles al respecto en la publicación oficial en el blog, donde se menciona que pronto, la limpieza de refs mediante null podría quedar obsoleta, dejando un único método estandarizado para limpiar refs.

Elección Entre Refs Normales y Callback Refs

  • Usa refs estándar (useRef) cuando solo necesitas acceso simple a un elemento del DOM o deseas preservar algún valor entre renderizaciones sin acciones adicionales al adjuntar o desajuntar.
  • Usa callback refs cuando requieres un control más detallado sobre el ciclo de vida del elemento, cuando estás escribiendo código universal (por ejemplo, en tu propia biblioteca o paquete), o cuando necesitas gestionar múltiples refs juntos.

Conclusión

Las refs de devolución de llamada en React son una herramienta poderosa que brinda a los desarrolladores una flexibilidad adicional y control al trabajar con elementos del DOM. En la mayoría de los casos, las refs de objeto estándar a través de useRef son suficientes para las tareas diarias, pero las refs de devolución de llamada pueden ayudar con escenarios más complejos como los que discutimos anteriormente.

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