useLayoutEffect

useLayoutEffect

Efectos secundarios que necesitan acceder al DOM de manera síncrona después de que React haya realizado sus cambios desde 16.8

Para poder entender cómo funciona useLayoutEffect lo primero que tenemos que plantear es el problema que viene a resolver y para ello vamos a partir de una pequeña aplicación muy sencilla en la que estaremos mostrando la información de un usuario (en concreto su identificador dentro del sistem y si se trata de un administrador o no) y un botón en el que simplemente al pulsarlo lo que vamos a hacer es cambiar al usuario que está usando la aplicación.

¿Qué es lo que sucederá en el momento en el que pulsamos sobre el botón Change User? Pues simplemente que se cambiará la información del usuario que se está mostrando mostrando su identificador (que evidentemente será diferente del de el usuario anterior) pero en este caso no será un administrador:

Ahora bien, el problema que nos puede pasar es que la actualización de estas dos informaciones de un usuario (su identificador y si se trata o no de un administrador) no tienen por qué suceder a la vez:

¿Qué es lo que está pasando aquí? Pues para entenderlo lo mejor es que veamos el código que estamos usando en nuestra aplicación de ejemplo que es algo como lo siguiente:


import { useEffect, useState } from 'react'

import Button from '@/components/ui/Button/Button'

const userIds = [1, 2]

export const Page = () => {
  const [userId, setUserId] = useState(userIds[0])
  const [isAdmin, setIsAdmin] = useState(true)

  // Código artificial para lograr que el rendering sea más lento.
  let now = performance.now()
  while (performance.now() - now < 200) {
    // No haremos nada puesto que queremos esperar.
  }

  useEffect(() => {
    setIsAdmin(userId === userIds[0])
  }, [userId])

  const handleChange = () => {
    const otherId = userIds.find(id => id !== userId)
  }

  return (
    <div>
      <p>userId: {userId}</p>
      <p>Admin: {isAdmin}</p>
      <Button onClick={handleChange} title='Change User' />
    </div>
  )
}

Como podemos en el código hemos simplificado al máximo el número de identificadores que pueden tener los usuarios en una variable userIds que estamos definiendo a fuera de nuestro componente.

Además tenemos dos atributos del estado del componente que serán los encargados de recoger cuál es el identificador del usuario que se está renderizando userId el cual inicializaremos con el valor del primer elemento del array usersIds y si se trata o no de un administrador de la aplicación isAdmin que inicializaremos a true indicando de esta manera que el primer usuario sí que es un adminstrador de la aplicación.

A continuación tenemos una parte en el código que lo que viene a hacer es introducir un mecanismo para poder hacer que el proceso de renderizado de nuestro componente sea mucho más lento puesto que va a tener que ejecutarlo y la razón de ello es que si no existierá este código el proceso de re-renderizado sería tan rápido que no podríamos ver el problema que estamos tratando de resolver.

Vemos además que tenemos un useEffect que estará escuchando por cambios en el atributo del estado userId y donde lo se hace es simplemente establecer el valor del atributo del estado isAdmin como true siempre que el identificador del usuario que ha sido recogido dentro del userId sea igual al identificador que está recogido en el primer elemento del array userIds. De esta manera, si el identificador del usuario es 1 lo que sucederá es que isAdmin será true y si es 2 será false.

Nota: aclarar que el código que tenemos recogido dentro de nuestro useEffect no se considera una buena práctica en React puesto que lo que estamos haciendo es establecer un valor de un atributo del estado del componente basándonos en el valor que tiene otro atributo del estado pero con el objetivo de mantener nuestro código de ejemplo lo más sencillo posible vamos a dejarlo de esta manera.

Definimos además la función handleChange que lo que viene a hacer es obtener el valor del identificador del otro usuario que no está seleccionado en ese momento (es decir, que no tiene el valor del mismo dentro del atributo del estado userId lo que quiere decir es que si está seleccionado el identificador 1 elegirá el 2 y viceversa) y por último establece el valor de userId a ese valor que ha seleccionado.

Por último nuestro componente nos muestra el código JSX que tiene asociado donde simplemente lo que tenemos son los elemenetos de la interfaz de usuario.

¿Dónde está el problema?

El problema al que nos estamos enfrentando tiene que ver con el useEffect que hemos definido puesto que en React este hook es asíncrono por naturaleza lo que quiere decir que el código que escribamos ahí dentro no va a bloquear el renderizado del componente (no bloqueará la ejecución del código JSX del componente) pero no solamente eso sino que además no tenemos la garantía de que el código que escribamos dentro del useEffect se vaya a ejecutar después de que se ejecute todo el código JSX.

Esto puede sonar un poco lioso así que vamos a detenernos un poquito más aquí viendo qué es lo que sucede en el momento en el que pulsamos el botón Change User en la interfaz:

  1. En primer lugar al pulsar sobre el botón lo que se hará será llamar a la función handleChange que es la que está vinculada al evento onClick sobre el botón.

  2. Dentro de la función se obtendrá el identificador del usuario que en ese momento no se está renderizando.

  3. Se establece el valor del userId al identificador del usuario que se ha obtenido en el punto anterior.

  4. Como estamos modificando un atributo del estado lo que React pasa a hacer es lanzar un nuevo re-renderizado del componente Page con el nuevo valor del userId.

  5. El proceso de renderizado llegará al código que está deteniendo el proceso de re-renderizado por al menos 200 milisegundos.

  6. Se llega al código del useEffect donde lo que se viene a hacer es programar (schedule) un cambio sobre la variable de estado isAdmin a true o false en función de si se trata de un administrador o no. Y aquí es donde tenemos que entender que como useEffect es asíncrono esta actualización quedará programada y no detendrá el proceso de re-rendering del componente.

  7. El proceso de re-renderizado pasará a ejecutar el código JSX donde se mostrará el valor actualizado de userId pero con el valor anterior de isAdmin puesto que todavía no se ha actualizado (está programada su actualización).

  8. Finaliza el código JSX y es el momento en que se ejecutará la programación de la actualización del valor de userId que teníamos como consecuencia del useEffect lo que actualizará su valor y por lo tanto se lanzará un nuevo proceso de re-renderizado puesto que se ha modificado un atributo del estado.

  9. Una vez más el proceso de re-rendizarado tiene que esperar los 200 milisegundos para poder continuar.

  10. Se llega una vez más al JSX donde ahora sí que se estarán mostrando los valores correctos que están asociados a cada uno de los atributos del estado.

Solución

La solución pasa por utilizar el hook useLayoutEffect en vez de useEffect por lo que el único cambio que vamos a hacer en nuestro código será el siguiente:


import { useLayoutEffect, useState } from 'react'

// ...

export const Page = () => {
  // ...
  useLayoutEffect(() => {
      setIsAdmin(userId === userIds[0])
    }, [userId])

Simplemente tras haber realizado este cambio vamos a ver qué es lo que sucede cuando volvemos a ejecutar nuestra aplicación donde podemos apreciar que el renderizado de los dos atributos del estado se realizan al mismo tiempo tal y como esperábamos:

Pero hay algo más en lo que deberíamos fijarnos en que aunque puede parecer que las cosas están funcionando tal y como queríamos vemos que el proceso de actualiazación no es inmediato sino que lleva una cierta cantidad de tiempo el tener la información actualizada desde que se pulsa sobre Change User y los nuevos valores se muestran en la página.

En la imagen anterior no se puede apreciar este problema puesto que no se aprecia el momento en el que se pulsa sobre el botón y el instante en el que se renderiza el resultado pero el problema está ahí.

¿Qué es lo que está pasando? Pues que gracias al uso de useLayoutEffect en total el proceso de actualización nos está llevando el doble de la cantidad de tiempo que implica la ejecución del código que relantiza el re-renderizado de nuestro componente (es decir, que tiene un coste de 400 milisegundos).

¿Qué es lo que está pasando?

Pues aquí nos toca hablar de cómo funciona useLayoutEffect y qué lo hace diferente de useEffect. Lo primero que tenemos que saber es que useLayoutEffect es exactamente lo mismo que useEffect lo quiere decir que le pasaremos la función que queremos ejecutar, el array de dependencias y la función de limpieza opcional pero su ejecución es síncrono lo que quiere decir que bloqueará el proceso de re-renderizado del componente hasta que la función que se ha de ejecutar no termine su ejecución.

Así pues ¿cuáles son los pasos que se ejecutan dentro de nuestro componente cuando pulsamos sobre el botón Change User una vez aplicamos useLayoutEffect?

  1. Pulsamos el botón lo que implica que se pase a ejecutar handleChange.

  2. Dentro de esta función pasaremos a actualizar el valor del atributo del estado userId.

  3. Al cambiar el valor del estado se produce un re-render lo que provocará que se ejecute el código lento (200 milisegundos).

  4. Como se ha producido un cambio en el userId se ejecutará el código que está dentro de useLayoutEffect puesto que, como hemos dicho, ahora la ejecución se produce de forma síncrona y dentro de esta ejecución se produce un nuevo cambio en uno de los atributos de estado (en este caso isAdmin) lo que provocará un nuevo re-renderizado.

  5. Se vuelve a ejecutar el código lento (otros 200 milisegundos).

  6. Se ejecuta el código JSX donde se muestran los valores actualizados.

A continuación se muestra el código completo de nuestro componente de ejemplo donde recogemos todos los cambios que hemos ido realizando:


import { useLayoutEffect, useState } from 'react'

import Button from '@/components/ui/Button/Button'

const userIds = [1, 2]

export const Page = () => {
  const [userId, setUserId] = useState(userIds[0])
  const [isAdmin, setIsAdmin] = useState(true)

  // Código artificial para lograr que el rendering sea más lento.
  let now = performance.now()
  while (performance.now() - now < 200) {
    // No haremos nada puesto que queremos esperar.
  }

  useLayoutEffect(() => {
    setIsAdmin(userId === userIds[0])
  }, [userId])

  const handleChange = () => {
    const otherId = userIds.find(id => id !== userId)
  }

  return (
    <div>
      <p>userId: {userId}</p>
      <p>Admin: {isAdmin}</p>
      <Button onClick={handleChange} title='Change User' />
    </div>
  )
}

Notas finales

Una de las desventajas que están asociados al uso de useLayoutEffect es que se trata de un hook que deberemos usar con cuidado porque no es bueno en términos de mejorar el rendimiento de nuestras páginas puesto que estamos bloqueando el proceso de render de un componente simplemente con el objetivo de ejecutar el código que está recogido dentro de este hook. Por lo tanto si en este código tenemos algo que sea complicado de obtener/calcular lo que estamos haciendo es bloquear todo el proceso de renderizado de nuestra aplicación hasta que se obtenga este valor lo que se puede traducir en una mala experiencia de usuario.

O, lo que sería todavía peor, es que nos encontrásemos con un componente que tiene múltiples useLayoutEffect en su interior todavía vamos a hacer que el proceso de renderizado sea mucho más lento puesto que el proceso de ejecución de cada uno de ellos va a detener el renderizado de nuestra aplicación.

Nota: deberemos ser muy cuidadosos a la hora de decidir utilizar useLayoutEffect puesto que su utilización tiene una implicación seria en el performance de nuestra aplicación.

Pero al ser useLayoutEffect idéntico a useEffect solo que el primero es síncrono y el segundo asíncrono ¿en qué situaciones deberíamos utilizarlo de forma segura? La respuesta es que por lo general nunca deberíamos utilizar useLayoutEffect ya que casi todos los escenarios que se nos pueden plantear seríamos capaces de resolverlo con useEffect lo que no implica que no tengamos que saber cómo funciona y cuál es la diferencia entre ambos hooks.