Manipular el DOM con Refs

React automáticamente actualiza el DOM para que coincida con tu salida de renderizado, por lo que tus componentes no necesitarán manipularlo con frecuencia. Sin embargo, a veces es posible que necesites acceder a los elementos del DOM gestionados por React, por ejemplo, enfocar un nodo, desplazarse hasta él, o medir su tamaño y posición. No hay una forma integrada para hacer ese tipo de cosas en React, por lo que necesitarás una ref al nodo DOM.

Aprenderás

  • Cómo acceder a un nodo DOM gestionado por React con el atributo ref
  • Cómo el atributo ref de JSX se relaciona con el Hook useRef
  • Cómo acceder al nodo DOM de otro componente
  • En qué casos es seguro modificar el DOM gestionado por React

Obtener una ref del nodo

Para acceder a un nodo DOM gestionado por React, primero importa el Hook useRef:

import { useRef } from 'react';

Luego, úsalo para declarar una ref dentro de tu componente

const myRef = useRef(null);

Finalmente, pasa la ref como el atributo ref a la etiqueta JSX en el que quieres obtener el nodo DOM:

<div ref={myRef}>

El Hook useRef devuelve un objeto con una sola propiedad llamada current. Inicialmente, myRef.current va a ser null. Cuando React cree un nodo DOM para este <div>, React pondrá una referencia a este nodo en myRef.current. Entonces podrás acceder a este nodo DOM desde tus manejadores de eventos y usar las API de navegador integradas definidas en él.

// Puedes usar cualquier API de navegador, por ejemplo:
myRef.current.scrollIntoView();

Ejemplo: Enfocar el campo de texto input

En este ejemplo, hacer clic en el botón va a enfocar el input:

import { useRef } from 'react';

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        Enfocar el input
      </button>
    </>
  );
}

Para implementar esto:

  1. Declara inputRef con el Hook useRef.
  2. Pásalo como <input ref={inputRef}>. Esto le dice a React que coloque el nodo DOM <input> en inputRef.current.
  3. En la función handleClick, lee el nodo input del DOM desde inputRef.current y llama a focus() en él con inputRef.current.focus().
  4. Pasa el manejador de eventos handleClick a <button> con onClick.

Mientras manipular el DOM es el caso de uso más común para las refs, el Hook useRef puede ser usado para almacenar otras cosas fuera de React, como las ID de temporizadores. De manera similar al estado, las refs permanecen entre renderizados. Las refs son como variables de estado que no desencadenan nuevos renderizados cuando las pones. Lee acerca de las refs en Referenciar valores con refs.

Ejemplo: Desplazarse a un elemento

Puedes tener más de una ref en un componente. En este ejemplo, hay un carrusel de tres imágenes. Cada botón centra una imagen al llamar al método del navegador scrollIntoView() en el nodo DOM correspondiente:

import { useRef } from 'react';

export default function CatFriends() {
  const firstCatRef = useRef(null);
  const secondCatRef = useRef(null);
  const thirdCatRef = useRef(null);

  function handleScrollToFirstCat() {
    firstCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToSecondCat() {
    secondCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToThirdCat() {
    thirdCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  return (
    <>
      <nav>
        <button onClick={handleScrollToFirstCat}>
          Tom
        </button>
        <button onClick={handleScrollToSecondCat}>
          Maru
        </button>
        <button onClick={handleScrollToThirdCat}>
          Jellylorum
        </button>
      </nav>
      <div>
        <ul>
          <li>
            <img
              src="https://placekitten.com/g/200/200"
              alt="Tom"
              ref={firstCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/300/200"
              alt="Maru"
              ref={secondCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/250/200"
              alt="Jellylorum"
              ref={thirdCatRef}
            />
          </li>
        </ul>
      </div>
    </>
  );
}

Profundizar

Cómo gestionar una lista de refs usando un callback ref

En los ejemplos de arriba, hay un número predefinido de refs. Sin embargo, algunas veces es posible que necesites una ref en cada elemento de la lista, y no sabes cuantos vas a tener. Algo como esto no va a funcionar:

<ul>
{items.map((item) => {
// ¡No funciona!
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>

Esto es porque los Hooks solo tienen que ser llamados en el nivel más alto de tu componente. No puedes llamar a useRef en un bucle, en una condición, o dentro de una llamada a map().

Una posible forma de evitar esto es hacer una sola ref a su elemento padre, y luego usar métodos de manipulación del DOM como querySelectorAll para «encontrar» los nodos hijos individuales a partir de él. Sin embargo, esto es frágil y puede romperse si la estructura del DOM cambia.

Otra solución es pasar una función al atributo ref. A esto se le llama un callback ref. React llamará tu callback ref con el nodo DOM cuando sea el momento de poner la ref, y con null cuando sea el momento de limpiarla. Esto te permite mantener tu propio array o un Map, y acceder a cualquier ref por su índice o algún tipo de ID.

Este ejemplo te muestra cómo puedes usar este enfoque para desplazarte a un nodo arbitrario en una lista larga:

import { useRef } from 'react';

export default function CatFriends() {
  const itemsRef = useRef(null);

  function scrollToId(itemId) {
    const map = getMap();
    const node = map.get(itemId);
    node.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function getMap() {
    if (!itemsRef.current) {
      // Inicializa el Map en el primer uso
      itemsRef.current = new Map();
    }
    return itemsRef.current;
  }

  return (
    <>
      <nav>
        <button onClick={() => scrollToId(0)}>
          Tom
        </button>
        <button onClick={() => scrollToId(5)}>
          Maru
        </button>
        <button onClick={() => scrollToId(9)}>
          Jellylorum
        </button>
      </nav>
      <div>
        <ul>
          {catList.map(cat => (
            <li
              key={cat.id}
              ref={(node) => {
                const map = getMap();
                if (node) {
                  map.set(cat.id, node);
                } else {
                  map.delete(cat.id);
                }
              }}
            >
              <img
                src={cat.imageUrl}
                alt={'Cat #' + cat.id}
              />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
}

const catList = [];
for (let i = 0; i < 10; i++) {
  catList.push({
    id: i,
    imageUrl: 'https://placekitten.com/250/200?image=' + i
  });
}

En este ejemplo, itemsRef no contiene un solo nodo DOM. En su lugar, contiene un Map desde el ID del elemento hasta un nodo DOM. (¡Las refs pueden contener cualquier valor!) El callback ref en cada elemento de la lista se encarga de actualizar el Map:

<li
key={cat.id}
ref={node => {
const map = getMap();
if (node) {
// Agregar al Map
map.set(cat.id, node);
} else {
// Eliminar del Map
map.delete(cat.id);
}
}}
>

Esto te permite leer nodos DOM individuales del Map más tarde.

Accediendo a nodos DOM de otros componentes

Cuando colocas una ref en un componente integrado que devuelve de salida un elemento del navegador como <input />, React establecerá la propiedad current de esa ref al nodo DOM correspondiente (como el <input /> real del navegador)

Sin embargo, si intentas poner una ref en tu propio componente, como <MyInput />, por defecto tendrás null. Aquí hay un ejemplo demostrándolo. Nota como al hacer clic en el botón no enfoca el input.

import { useRef } from 'react';

function MyInput(props) {
  return <input {...props} />;
}

export default function MyForm() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Enfocar el input
      </button>
    </>
  );
}

Para ayudarte a notar el problema, React también mostrará un error en la consola.

Console
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
(Traducción)
Advertencia: Los componentes de función no pueden recibir refs. Los intentos de acceder a esta ref fallarán. ¿Querías usar React.forwardRef()?

Esto ocurre porque por defecto React no permite que un componente acceda a los nodos DOM de otros componentes. ¡Ni siquiera a sus propios hijos! Esto es intencionado. Las refs son una vía de escape que debe usarse con moderación. Manipular manualmente los nodos DOM de otro componente hace tu código aún más frágil.

En cambio, los componentes que quieran exponer sus nodos DOM tienen que optar por ese comportamiento. Un componente puede especificar que «reenvíe» su ref a uno de sus hijos. Aquí vemos como MyInput puede usar la API forwardRef:

const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});

Así es como funciona:

  1. <MyInput ref={inputRef} /> le dice a React que coloque el nodo DOM correspondiente en inputRef.current. Sin embargo, depende del componente MyInput utilizarlo o no; por defecto no lo hace.
  2. El componente MyInput es declarado usando forwardRef. Esto hace que pueda optar por recibir el inputRef como segundo argumento de ref la cual está declarada después de props.
  3. MyInput por si mismo pasa la ref que recibió del <input> dentro de él.

Ahora al hacer clic en el botón para enfocar el input, funciona:

import { forwardRef, useRef } from 'react';

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Enfocar el input
      </button>
    </>
  );
}

En diseño de sistemas, es un patrón común para componentes de bajo nivel como botones, inputs, etc. reenviar sus refs a sus nodos DOM. Por otro lado, los componentes de alto nivel como formularios, listas, o secciones de página, usualmente no suelen exponer sus nodos DOM para evitar dependencias accidentales de la estructura del DOM.

Profundizar

Exponiendo un subconjunto de la API con un manejador imperativo

En el ejemplo de arriba, MyInput expone el elemento input del DOM original. Esto le permite al componente padre llamar a focus() en él. Sin embargo, esto también le permite al componente padre hacer otra cosa, por ejemplo, cambiar sus estilos CSS. En casos pocos comunes, quizás quieras restringir la funcionalidad expuesta. Puedes hacer eso con useImperativeHandle:

import {
  forwardRef, 
  useRef, 
  useImperativeHandle
} from 'react';

const MyInput = forwardRef((props, ref) => {
  const realInputRef = useRef(null);
  useImperativeHandle(ref, () => ({
    // Solo expone focus y nada más
    focus() {
      realInputRef.current.focus();
    },
  }));
  return <input {...props} ref={realInputRef} />;
});

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Enfocar el input
      </button>
    </>
  );
}

Aquí, realInputRef dentro de MyInput mantiene el nodo DOM de input actual. Sin embargo, useImperativeHandle indica a React a proveer tu propio objeto especial como el valor de una ref al componente padre. Por lo tanto, inputRef.current dentro del componente Form solo va a tener el método focus. En este caso, el «manejador» ref no es el nodo DOM, sino el objeto personalizado que creaste dentro de la llamada de useImperativeHandle.

Cuando React adjunta las refs

En React, cada actualización está dividida en dos fases:

  • Durante el renderizado, React llama a tus componentes para averiguar que debería estar en la pantalla.
  • Durante la confirmación, React aplica los cambios a el DOM.

En general, no quieres acceder a las refs durante el renderizado. Eso va también para las refs que tienen nodos DOM. Durante el primer renderizado, los nodos DOM aún no han sido creados, entonces ref.current será null. Y durante el renderizado de actualizaciones, los nodos DOM aún no se han actualizado. Es muy temprano para leerlos.

React establece ref.current durante la confirmación. Antes de actualizar el DOM, React establece los valores afectados de ref.current a null. Después de actualizar el DOM, React inmediatamente los establece en los nodos DOM correspondientes.

Generalmente, vas a acceder a las refs desde los manejadores de eventos. Si quieres hacer algo con una ref, pero no hay un evento en particular para hacerlo, es posible que necesites un Efecto. Discutiremos los efectos en las próximas páginas.

Profundizar

Vaciando actualizaciones de estado sincrónicamente con flushSync

Considere un código como el siguiente, que agrega un nuevo todo y desplaza la pantalla hasta el último hijo de la lista. Observa cómo, por alguna razón, siempre se desplaza hacia el todo que estaba justo antes del último que se ha agregado.

import { useState, useRef } from 'react';

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    setText('');
    setTodos([ ...todos, newTodo]);
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        Agregar
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: 'Todo #' + (i + 1)
  });
}

El problema está con estas dos lineas:

setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();

En React, las actualizaciones de estados se ponen en cola. Generalmente, esto es lo que quieres. Sin embargo, aquí causa un problema porque setTodos no actualiza el DOM inmediatamente. Entonces, en el momento en el que desplazas la lista al último elemento, el todo aún no ha sido agregado. Esta es la razón por la que al desplazarse siempre se «retrasa» en un elemento.

Para arreglar este problema, puedes forzar a React a actualizar («flush») el DOM sincrónicamente. Para hacer esto, importa flushSync del react-dom y envuelve el actualizador de estado en una llamada a flushSync:

flushSync(() => {
setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();

Esto le indicará a React que actualice el DOM sincrónicamente justo después que el código envuelto en flushSync se ejecute. Como resultado, el último todo ya estará en el DOM en el momento que intentes desplazarte hacia él.

import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    flushSync(() => {
      setText('');
      setTodos([ ...todos, newTodo]);      
    });
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        Agregar
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: 'Todo #' + (i + 1)
  });
}

Mejores prácticas para la manipulación del DOM con refs

Las refs son una vía de escape. Sólo deberías usarlas cuando tengas que «salirte de React». Ejemplos comunes de esto incluyen la gestión del foco, la posición del scroll, o una llamada a las API del navegador que React no expone.

Si te limitas a acciones no destructivas como enfocar o desplazarte, no deberías encontrar ningún problema. Sin embargo, si intentas modificar el DOM manualmente, puedes arriesgarte a entrar en conflicto con los cambios que React está haciendo.

Para ilustrar este problema, este ejemplo incluye un mensaje de bienvenida y dos botones. El primer botón alterna su presencia usando renderizado condicional y estado, como normalmente lo harías en React. El segundo botón usa la API del DOM remove() para eliminarlo forzadamente del DOM fuera del control de React.

Intenta presionar «Alternar con setState» unas cuantas veces. El mensaje debe desaparecer y aparecer otra vez. Luego presiona «Eliminar del DOM». Esto lo eliminará forzadamente. Finalmente, presiona «Alternar con setState»:

import { useState, useRef } from 'react';

export default function Counter() {
  const [show, setShow] = useState(true);
  const ref = useRef(null);

  return (
    <div>
      <button
        onClick={() => {
          setShow(!show);
        }}>
        Alternar con setState
      </button>
      <button
        onClick={() => {
          ref.current.remove();
        }}>
        Eliminar del DOM
      </button>
      {show && <p ref={ref}>Hola Mundo</p>}
    </div>
  );
}

Después de que hayas eliminado el elemento DOM, intentar usar setState para mostrarlo de nuevo provocará un fallo. Esto se debe a que has cambiado el DOM, y React no sabe cómo seguir gestionándolo correctamente.

Evita cambiar nodos DOM gestionados por React. Modificar, agregar hijos, o eliminar hijos de elementos que son gestionados por React pueden traer resultados inconsistentes visuales o fallos como el de arriba.

Sin embargo, esto no quiere decir que no puedas en absoluto. Requiere de cuidado. Puedes modificar de manera segura partes del DOM que React no tenga motivos para actualizar. Por ejemplo, si algún <div> siempre está vacío en el JSX, React no tendrá un motivo para tocar su lista de elementos hijos. Por lo tanto, es seguro agregar o eliminar manualmente elementos allí.

Recapitulación

  • Las refs son un concepto genérico, pero a menudo las vas a usar para almacenar elementos del DOM.
  • Tú le indicas a React a poner un nodo DOM dentro de myRef.current pasándole <div ref={myRef}>.
  • Normalmente, vas a usar las refs para acciones no destructivas como enfocar, desplazar, o medir elementos DOM.
  • Un componente no expone sus nodos DOM por defecto. Puedes optar por exponer un nodo DOM usando forwardRef y pasando el segundo argumento ref a un nodo específico.
  • Evita cambiar nodos DOM gestionados por React.
  • Si modificas nodos DOM gestionados por React, modifica las partes en donde React no tenga motivos para actualizar.

Desafío 1 de 4:
Reproduce y pausa el video

En este ejemplo, el botón alterna una variable de estado para cambiar entre un estado de reproducción y un estado de pausa. Sin embargo, para que reproduzca o pause el video, alternar el estado no es suficiente. También necesitas llamar a play() y pause() en el elemento DOM para el <video>. Agrega una ref en él, y haz que el botón funcione.

import { useState, useRef } from 'react';

export default function VideoPlayer() {
  const [isPlaying, setIsPlaying] = useState(false);

  function handleClick() {
    const nextIsPlaying = !isPlaying;
    setIsPlaying(nextIsPlaying);
  }

  return (
    <>
      <button onClick={handleClick}>
        {isPlaying ? 'Pausar' : 'Reproducir'}
      </button>
      <video width="250">
        <source
          src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
          type="video/mp4"
        />
      </video>
    </>
  )
}

Para un desafío extra, mantén el botón «Reproducir» sincronizado con la reproducción del vídeo, incluso si el usuario hace clic con el botón derecho del ratón en el vídeo y lo reproduce utilizando los controles multimedia integrados en el navegador. Para ello, es posible que quieras escuchar onPlay y onPause en el vídeo.