ESLint: Cannot access refs during render (react-hooks/refs)

Con eslint-config-next en un proyecto Next.js 16, eslint puede marcar como error código de refs que funciona en ejecución. Hay dos mensajes frecuentes. El primero aparece al leer una propiedad de un objeto que devuelve un hook personalizado y que contiene una ref:

error  Error: Cannot access refs during render

React refs are values that are not needed for rendering. Refs should only be
accessed outside of render, such as in event handlers or effects. [...]

> 6 |     <div ref={panel.containerRef}>
    |               ^^^^^^^^^^^^^^^^^^ Cannot access ref value during render
  react-hooks/refs

El segundo, al asignar .current a una ref que llega como propiedad o como argumento:

error  Error: This value cannot be modified

Modifying component props or hook arguments is not allowed. Consider using a
local variable instead.

Hint: If this value is a Ref (value returned by `useRef()`), rename the
variable to end in "Ref"
  react-hooks/immutability

Los dos se corrigen sin desactivar las reglas. Las pruebas de este artículo se hicieron con Next.js 16.4.0, eslint-config-next 16.4.0 y eslint-plugin-react-hooks 7.1.1; el comportamiento exacto del análisis puede variar entre versiones.

Por qué pasa

La documentación de Next.js indica que eslint-config-next incluye los conjuntos de reglas recomendados de eslint-plugin-react-hooks, y la documentación de React lista refs e immutability entre las reglas del preset recommended. En el proyecto de prueba las reglas se ejecutaron sin que existiera ninguna configuración del compilador de React.

  • react-hooks/refs valida que no leas ni escribas ref.current durante el render, porque la ref puede no estar inicializada y su valor puede estar desactualizado. Para saber qué es una ref, el análisis usa tres señales: el valor viene de useRef() o createRef(), es un identificador llamado ref o que termina en Ref y accede a .current, o se pasa por la propiedad JSX ref. Y esa inferencia sigue al valor a través de asignaciones, desestructuración y funciones auxiliares. Por eso un objeto que contiene una ref queda "contaminado": en la prueba, leer panel.containerRef, panel.open y panel.toggle dio un error por cada lectura.
  • react-hooks/immutability valida que no se modifiquen props, estado ni argumentos de hooks. Una asignación como props.forwardedRef.current = node la marcó porque el análisis no reconoce ahí una ref. La misma asignación con una variable desestructurada llamada forwardedRef no se marcó, porque el nombre termina en Ref.

Solución

  1. Ejecuta el linter directamente. En Next.js 16 el comando next lint ya no existe; se usa la CLI de ESLint:

    npx eslint .
  2. Si el hook crea la ref y la devuelve dentro de un objeto, crea la ref en el componente y pásala al hook. Código con el fallo:

    export function useScrollPanel() {
      const containerRef = useRef(null);
      const [open, setOpen] = useState(false);
      return { containerRef, open, toggle: () => setOpen((v) => !v) };
    }
    
    export function Panel() {
      const panel = useScrollPanel();
      return (
        <div ref={panel.containerRef}>
          <button onClick={panel.toggle}>{panel.open ? 'Cerrar' : 'Abrir'}</button>
        </div>
      );
    }

    Código corregido, sin errores en la prueba:

    export function useScrollPanel(containerRef) {
      const [open, setOpen] = useState(false);
      const scrollToTop = () => containerRef.current?.scrollTo({ top: 0 });
      return { open, toggle: () => setOpen((v) => !v), scrollToTop };
    }
    
    export function Panel() {
      const containerRef = useRef(null);
      const { open, toggle, scrollToTop } = useScrollPanel(containerRef);
      return (
        <div ref={containerRef}>
          <button onClick={toggle}>{open ? 'Cerrar' : 'Abrir'}</button>
          <button onClick={scrollToTop}>Subir</button>
        </div>
      );
    }

    Leer containerRef.current dentro de un manejador de eventos está permitido; lo que la regla evita es hacerlo mientras se renderiza.

  3. Si recibes una ref de un padre, no asignes .current a mano. Guarda el elemento en estado con una ref de callback y expónlo con useImperativeHandle (en React 19, ref llega como una propiedad normal):

    import { useImperativeHandle, useState } from 'react';
    
    export function Contenido({ ref: forwardedRef, children }) {
      const [contentElement, setContentElement] = useState(null);
    
      useImperativeHandle(forwardedRef, () => contentElement, [contentElement]);
    
      return <div ref={setContentElement}>{children}</div>;
    }

    El setter de useState es una función, así que sirve como ref de callback. React llama a useImperativeHandle de nuevo cuando contentElement cambia y asigna el nuevo valor a la ref del padre.

  4. Si el padre necesita el nodo en su efecto de montaje, usa una ref interna. Con el estado del paso anterior, la ref del padre vale null durante el primer efecto de montaje, porque el elemento se guarda en un segundo render (se observa con una prueba sencilla). Esta variante también pasa el linter y entrega el nodo desde el primer montaje:

    export function Contenido({ ref: forwardedRef, children }) {
      const innerRef = useRef(null);
    
      useImperativeHandle(forwardedRef, () => innerRef.current, []);
    
      return <div ref={innerRef}>{children}</div>;
    }

Una alternativa más corta para el caso del hook: desestructurar en la misma línea (const { containerRef, open, toggle } = useScrollPanel()) no produjo errores en la prueba. Funciona por la heurística del análisis, así que es más frágil que pasar la ref como argumento.

Cómo verificar que quedó resuelto

npx eslint .
npx tsc --noEmit

eslint no debe mostrar ningún react-hooks/refs ni react-hooks/immutability. Después comprueba el comportamiento: con Testing Library, renderiza el componente con una ref de padre y comprueba que ref.current es el elemento div. En las pruebas hechas para este artículo, el elemento se entregó correctamente con las variantes de estado y de ref interna.

Variantes y errores relacionados

React 18 y forwardRef

La documentación de React indica que, desde React 19, ref está disponible como propiedad; en React 18 y anteriores hay que obtenerla con forwardRef. El cuerpo de las soluciones es el mismo: forwardRef(function Contenido({ children }, forwardedRef) { ... }).

Lecturas que sí están permitidas

Leer o escribir ref.current en efectos y manejadores de eventos es válido. La documentación de la regla también acepta la inicialización perezosa: if (ref.current === null) ref.current = crear(); durante el render.

Un objeto normal con una propiedad current

Según la documentación de la regla, el análisis trata como ref cualquier identificador llamado ref o terminado en Ref que lea o escriba .current. Si no es una ref de React, cambia el nombre (por ejemplo, caja). Si sí lo es, el mensaje sugiere lo contrario: renómbrala para que termine en Ref.

Desactivar la regla

La documentación de Next.js muestra cómo cambiar o desactivar reglas de react-hooks con la propiedad rules de eslint.config.mjs. Hazlo solo si entiendes el riesgo: las lecturas de refs durante el render son justo el tipo de código que puede producir pantallas desactualizadas.

Resumen

  • react-hooks/refs prohíbe leer o escribir ref.current durante el render, y un objeto que contiene una ref también se considera "ref".
  • Crea la ref en el componente y pásala al hook, o desestructura el resultado del hook en la misma línea.
  • react-hooks/immutability prohíbe modificar props y argumentos; para combinar con una ref recibida usa useImperativeHandle en lugar de asignar .current.
  • Una ref de callback basada en estado deja la ref del padre en null hasta el segundo render; usa una ref interna si el padre la necesita al montar.
  • En Next.js 16 ejecuta npx eslint .; next lint fue eliminado.

Referencias

Comentarios