Optimizar React

Optimizar React

Aprenderemos a utilizar `useMemo` y `useCallback` de forma correcta con el objetivo de optimizar nuestras aplicaciones.

Es posible que cuando nos dicen que tenemos que optimizar nuestras aplicaciones de React en lo primero que pensemos es en utilizar hooks como useMemo o useCallback para lograrlo pero también es posible que pasemos a utilizarlos de forma incorrecta lo que acabe traduciéndose en un peor problema de rendimiento.

En este artículo vamos a ver una serie de ejemplos en los que se estarán utilizando estos dos hooks con la finalidad de lograr que el rendimiento de nuestra aplicación mejore pero lo que se logre sea precisamente todo lo contrario, que nuestra aplicación vaya mucho más lenta. Además veremos técnicas que nos ayudarán a optimizar nuestras aplicaciones de React de forma correcta.

Ejemplo 1

Vamos a comenzar con un componente muy sencillo al que llameremos Example en el que definimos un atributo para su estado con el uso del hook useState donde nuestra intención es guardar un usuario. Es más, dentro del fichero donde se declara nuestro componente estaremos definiendo también el tipo de dato que está asociado a un usuario (el tipo User) donde lo único que tenemos es el identificador del usuario (`id`) y el número de amigos que posee (`friendsCount`):

import { useState } from 'react'

interface User {
  id: number
  friendsCount: number
}

export const Example = () => {
  const [user, setUser] = useState<User>({
    id: 1,
    friendsCount: 2000
  })

  return <div></div>
}

Vamos a suponer que este componente forma parte de un proyecto mucho más grande de React y que se nos asigna una tarea que consistirá en añadir una serie de estadísticas relacionadas con cada usuario y además mostrarlas en la UI.

Nota: en este ejemplo sencillo vamos a suponer que las estadísticas no vienen del backend sino que van a tener que ser calculadas en el frontend.

¿Qué podemos hacer para lograr nuestro objetivo? Pues podemos empezar definiendo un nuevo objeto que contendrá la información de las estadísticas.

const userStats = {}

Ahora comenzamos definiendo cada uno de los atributos que forman parte de este objeto. Empezaremos definiendo isPopular que viene a denotar, como su nombre en inglés indica, si un usuario puede ser considerado como popular o no de tal manera que si el usuario tienen más de 1000 amigos los será y en caso contrario no:

const userStats = {
  isPopular: user.friendsCount > 1000
}

Vamos a definir un segundo atributo dentro de este objeto al que vamos a denominar isNew y que viene a indicar si el usuario en cuestión puede ser considerado como nuevo dentro de nuestra aplicación y con el fin de mantener las cosas sencillas vamos a suponer que se considerará que un usuario es nuevo siempre que su identificador de usuario sea menor que 10, lo que nos deja algo como:

const userStats = {
  isPopular: user.friendsCount > 1000,
  isNew: user.id < 10
}

El siguiente paso será mostrar esta información en la UI de nuestra aplicación de tal manera que si se estamos ante un usuario popular se mostrará un mensaje indicándolo y lo mismo sucederá en el caso de que estemos ante un nuevo usuario. Esto nos dejará un código JSX como el siguiente:

return (
  <div>
    { userStats.isPopular && <p>Popular user</p>}
    { userStats.isNew && <p>New user</p>}
  </div>
)

A continuación mostramos el código completo de nuestro componente una vez le hemos incorporado todas estas modificaciones:


import { useState } from 'react'

interface User {
  id: number
  friendsCount: number
}

export const Example = () => {
  const [user, setUser] = useState<User>({
    id: 1,
    friendsCount: 2000
  })

  const userStats = {
    isPopular: user.friendsCount > 1000,
    isNew: user.id < 10
  }

  return (
    <div>
      { userStats.isPopular && <p>Popular user</p>}
      { userStats.isNew && <p>New user</p>}
    </div>
  )
}

Lo que tenemos que pensar en este punto es que el código de nuestro componte es totalmente correcto y en muchas situaciones lo que nos vamos a encontrar es con que se decide utilizar useMemo para obtener el objeto que tiene todo el valor de las estadísticas de tal manera que nuestra intención última será prevenir la aparición de renders que no son necesarios. Esto nos dejaría un código como el siguiente:

const userStats = useMemo(() => ({
    isPopular: user.friendsCount > 1000,
    isNew: user.id < 10
  }),
  [user]
)

Así cuando se cambie el usuario user será el momento en el que cambiará el objeto que contiene sus estadísticas. Es más, con esta implemenetación lo que queremos asegurar es que el código sea lo más óptimo posible y no resulta evidente entender que al utilizar useMemo en esta situación con lo que nos encontramos es con un peor rendimiento que cuando no lo estábamos usando.

Esta afirmación no resulta para nada intuitiva y para poderlo entender tenemos que pensar en cómo funciona useMemo y qué es lo que hace. Como ya sabemos useMemo es un hook que lo que viene a hacer es cogelar (freeze) un valor (en nuestro caso el objeto que tiene los atributos isPopular y isNew) de tal manera que solamente actualizará ese valor (nuestro objeto con las estadísticas) cuando exista una cambio en el array de dependencias que está asociado a la definición del hook.

Para lograr determinar cuando hay un cambio en el array de dependencias internamente useMemo lo que está haciendo es una serie de comparaciones entre el nuevo valor del array de dependencias y el antiguo valor de tal manera que si detecta que ha habido un cambio entonces será el momento en el que vuelve a recalcular el valor (vuelve a ejecutar la función que se le ha pasado como primer parámetro). Bien, todos estas comprobaciones para determinar si un valor dentro del array de depencias ha cambiado o no no son gratis desde el punto de vista del tiempo de computación puesto que en cada re-render que se produzca useMemo las va a tener que volver a realizar para determinar si se debe recalcular el valor o no.

En definitiva el uso de useMemo tiene un coste de computación adicional en cada re-render por lo que tendríamos que estar seguros de que es necesario usarlo basándonos en el fin último del mismo que no es otro que el de ahorrarnos tiempo cuando los valores que tiene asociados tienen un coste algo en tiempo de computación.

Volviendo a nuestro ejemplo en el cálculo de del objeto userStats no hay nada que tenga un coste computacional que sea excesivo puesto que lo único que estamso haciendo es crear un nuevo objeto y para la creación de sus atributos nos basamos en los valores que tiene el atributo del estado user asignando un valor booleano en función de los valores de estos atributos de user.

No estamos iterando sobre un array formado por muchos elementos, no estamos haciendo uso de operaciones con un coste de computación elevado (no estamos haciendo operaciones matemáticas complejas o estamos trabajando con algoritmos de criptrografía).

En definitiva el uso de useMemo cuando no estamos haciendo operaciones complejas en última instancia se traduce en un peor rendimiento puesto que el conjunto de operaciones que se tienen que realizar para que useMemo vea si tiene que calcular un nuevo valor o no, es mucho más costoso que el proceso de creación del objeto y asignación de sus atributos.

Entonces ¿cuál es la forma correcta de optimizar el componente Example? Pues no haciendo nada sobre el mismo dejando nuestro código sin el uso de useMemo.

Ejemplo 2

Para mostrar el segundo caso a actualizar vamos a continuar con el mismo ejemplo pero en este caso lo que se hace es crear el objeto con la información de las estadísticas de forma óptima y una vez lo tenemos se lo vamos a pasar a un componente hijo, es decir, que estaríamos ante un código como el siguiente:

import { useState } from 'react'

interface User {
  id: number
  friendsCount: number
}

export const Example = () => {
  const [user, setUser] = useState<User>({
    id: 1,
    friendsCount: 2000
  })

  const userStats = {
    isPopular: user.friendsCount > 1000,
    isNew: user.id < 10
  }

  return <div>
    <UserStats userStats={userStats} />
  </div>
}

Ahora nos queda por mostrar el código que define al componente UserStats que será algo tan sencillo como lo que se puede ver a continuación:

export const UserStats = ({ userStats }: { isPopular: boolean, isNew: boolean }) => {
  return (
    <div>
      { userStats.isPopular && <p>Popular user</p>}
      { userStats.isNew && <p>New user</p>}
    </div>
  )
}

¿Qué deberíamos hacer en un caso como este? Pues en este escenario tampoco es aconsejable hacer uso de useMemo a la hora de determinar el valor de userStats sabiendo que cuando lo usamos vamos a asumir un coste de computación adicional por lo que siempre deberíamos ser capaces de justificar el por qué de su utilización.

En el ejemplo lo primero en lo que tenemos que fijarnos es que UserStats realmente es un componente muy ligero puesto que es básicamente un componente que lo único que hace será posicionarse dentro del DOM de al página en donde se esté renderizando lo que lo convierte en un componente que se renderizará muy rápido y por lo tanto no justifica el uso de useMemo para congelar el valor de su única prop y así no tener que re-renderizarse.

Una vez mas, en el ejemplo asumimos que en cada re-render se vuelve a calcular el valor de userStats pero esto tiene un coste computacional tan bajo que no nos importa. Es más, como el componet UserStats es tan ligero su renderizado será muy rápido y por lo tanto no podremos justificar claramente el uso de useMemo para calcular el valor de la única prop que recibe.

Por lo tanto la forma en la que deberíamos proceder cuando estamos trabajando con useMemo y más concretamente para determinar si es justificable o no su utilización es viendo si en cada re-render se va a ejecutar parte del código que tenga un coste computacional alto o simplemente lo que estamos haciendo es tratando de optimizar nuestro código de forma prematura sin que haya nada que justifique esta optimización.

Ejemplo 3

Ahora que ya hemos visto dos casos en los que el uso de useMemo no está justificado vamos a ver uno en el que sí que lo está. Vamos a centrarnos en el componente UseStats pero este caso suponiendo que dentro del mismo esta vez sí que se ha de calcular un valor que tiene un coste computacional alto:

export const UserStats = ({ userStats }: { isPopular: boolean, isNew: boolean }) => {
  const expensiveValue = useMemo(() => {
    // Aquí iría el código con un alto coste de computación.
  }, [userStats])

  return (
    <div>
      { userStats.isPopular && <p>Popular user</p>}
      { userStats.isNew && <p>New user</p>}
    </div>
  )
}

Vemos que el cálculo de expensiveValue únicamente se volverá a realizar en el caso de que la prop userStats cambie ahora bien, si nos fijamos en detalle en lo que está sucediendo cada vez que se produzca un re-render del componete padre Example se vuelve a obtener el valor de userStats retornando una referencia nueva y por lo tanto cuando dicho objeto llega como prop a UserStats se volverá a hacer el cálculo de expendiveValue puesto que la prop no volverá a ser la misma entre dos re-renders (cambia la referencia del objeto).

¿Qué deberemos hacer en este caso? Pues en un escenario como este es necesario que utilicemos también useMemo en el componente padre si no queremos que los cálculo se vuelvan a realizar en cada uno de los re-renders lo que nos dejará un código como el siguiente:

import { useState } from 'react'

interface User {
  id: number
  friendsCount: number
}

export const Example = () => {
  const [user, setUser] = useState<User>({
    id: 1,
    friendsCount: 2000
  })

  const userStats = useMemo(() => ({
      isPopular: user.friendsCount > 1000,
      isNew: user.id < 10
    }),
    [user]
  )

  return <div>
    <UserStats userStats={userStats} />
  </div>
}

de esta manera el valor de userStats solamente será recalculado cuando se produce un cambio en user (así está especificado en el array de dependencias) por lo que cuando es pasado a UserStats será el mismo (es la misma referencia) y por lo tanto no tendrá que volver a realizar los cálculos con el coste computacional alto asegurando de esta manera que el valor de expensiveValue únicamnte será calculado cuando es extrictamente necesario.

Nota: también podríamos haber usado React.memo con el componente UserStats y lograr el mismo resultado que acabamos de describir.

La forma de pensar que hemos ido aplicando cuando hemos estado mostrando estos ejemplos es la que deberemos aplicar cuando estemos hablando de optimizar React. De esta manera no deberíamos estar usando useMemo y useCallback de forma indiscriminada por nuestro código puesto que esto tiene un coste y por lo tanto su coste de utilización puede ser superior al coste de no haberlos utilizado.

Es más, aunque no los hemos desarrollado para no alargar la explicación en exceso, el uso de useCallback es similar pero pensando siempre que en este caso lo que retornará será una función y no una valor.

Ejemplo 4

Volvamos al código de nuestro componente de partida pero ahora vamos a pensar en la manera que tenemos de saber si un usuario es popular o no pero siempre desde una perspectiva de la optimización (dicho de otra manera, cuál es la forma más óptima de obtener su valor):

import { useState } from 'react'

interface User {
  id: number
  friendsCount: number
}

export const Example = () => {
  const [user, setUser] = useState<User>({
    id: 1,
    friendsCount: 2000
  })

  return <div></div>
}

Una de las soluciones que muchos desarrolladores adoptan es crear un nuevo atributo del estado del componente que sirva para recoger si estamos ante un usuario popular o no. Es decir, que haríamos algo como lo siguiente:

const [user, setUser] = useState<User>({
const [isPopular, setIsPopular] = useState(false)

Hecho esto lo siguiente que haríamos sería hacer uso de un useEffect donde estableceremos como dependencia el user con el fin de que cuando cambie el usuario se establezca el valor de isPopular. El useEffect sería algo como lo siguiente:

useEffect(() => {
  setIsPopular(user.friendsCount > 1000)
}, [user])

Este código funciona correctamente (es un código válido) y no se puede poner ningún tipo de pero desde el punto de vista funcional. Pero ¿esta implementación desde el punto de vista de la optimización es correcta?

La respuesta, como nos podemos imaginar, es no puesto que estamos defiendo un atributo del estado isPopular y no solamente eso sino que estamos definiendo un effect que tendremos que tener mantener para establecer el valor de este atributo del estado cuando nada de todo esto es necesario.

¿Qué podemos hacer para solucionarlo? Pues simplemente obtener el valor que indica si un usuario es popular o no directamente del estado y asociándolo a una variable como sigue:

const isPopular = user.friendsCount > 1000

Con esto no solamente hemos eliminado todos aquellos elementos que no son necesarios como el atributo del estado y el effect sino que además hemos eliminado un ciclo de re-renderizado del propio componente Example ya que con la implementación que teníamos anteriormente cada vez que cambiaba el usuario se dispararía el código que estaba dentro de useEffect pudiendo cambiar el valor del atributo del estado isPopular y provocando así un re-render que no sería necesario.

Pensemos además que si nuestros componentes tienen varios useEffect definidos dentro del mismo es más que probable que se produzcan efectos en cadena de los mismos derivados de que se cambien valores de sus dependencias lo que podría provocar re-renders adicionales cosa que para nada queremos cuando estamos hablando de performance.

Por lo tanto, cuando necesitemos definir una variable derivada de un atributo de un objeto (como es el caso del ejemplo que nos ocupa) deberemos tratar de ser inteligentes y preguntarnos si la podemos obtener de forma directa sin tener que complicar nuestro código de forma artificial.

El código completo optimizado para el escenario que estamos planteando es el que se puede ver a continuación:

import { useState } from 'react'

interface User {
  id: number
  friendsCount: number
}

export const Example = () => {
  const [user, setUser] = useState<User>({
    id: 1,
    friendsCount: 2000
  })

  const isPopular = user.friendCount > 1000

  return <div></div>
}