Utiliza Context Para el Manejo de Estado en tu Aplicación React

Tiempo
~21m

Cuando construimos aplicaciones en React, es común tener componentes que guardan datos en su estado, los cuales son transmitidos a otros componentes de arriba hacia abajo a través de props. Hay ocasiones que incluso queremos actualizarlos desde algún descendiente.

Para poder hacer esto de forma más simple, React nos entrega Context, una forma de pasar un dato de un componente padre a un descendiente directamente, sin intermediarios. Esto nos ayuda a lograr el manejo de estado global para un árbol de componentes sin utilizar librerías externas.

En este artículo vas a aprender a utilizar React Context API junto a otros hooks para manejar el estado de tus aplicaciones.

Requisitos:

  • Familiaridad renderizando componentes con React.
  • Saber utilizar el hook useState y props en React.

Lo que vas a aprender:

  • Aprenderás sobre el concepto de prop drilling y cómo evitarlo con Context.
  • Qué es Context y cómo utilizarlo.
  • Cómo utilizar Context junto a otros hooks para lograr manejar el estado de tu aplicación.
  • Cómo funciona Context con el renderizado (y cómo evitar errores comunes).

Lo que construiremos:

Construiremos una aplicación de juguete inspirada en el "layout" de Github, donde tienes información de un usuario (el username) tanto en el "sidebar" como en la cabecera. Vista previa de la aplicación a construir:

previewLoading
  • Veremos cómo podemos compartir el nombre de usuario entre dos componentes de manera sencilla, y luego cómo poder hacerlo utilizando Context.
  • Veremos cómo podemos implementar el botón “Randomizar”, el cual cambia las mayúsculas y minúsculas del nombre de usuario aleatoriamente (ejemplo: muZk → MUzk), de manera de que actualice el texto tanto en el "sidebar" como en el "header".

No nos centraremos en las reglas de estilo (CSS) para lograr este layout. Sin embargo, siempre puedes verlas en el código final disponible en CodeSandbox.

¿Cómo compartir estado entre componentes?

Para efectos didácticos, partiremos implementando el “cuerpo” del layout que incluye todo excepto el header, también omitiremos el botón “randomizar” por ahora:

Perfil de un usuario donde aparece el nombre, nombre de usuario, seguidores y seguidos:

preview sin headerLoading

Para lograr esto, debemos seguir la siguiente estructura:

💡

Utiliza useState para guardar la información del usuario porque normalmente la obtendríamos desde alguna una API (con fetch y useEffect por ejemplo). Como el objetivo de este artículo es manejo de estado, omití ese paso.

Tenemos la base. El siguiente paso será agregar el Header que dice “Sesión iniciada como @muZk”. La misma imagen anterior, pero esta vez con un Header que indica el usuario que inicio sesión. Queremos lograr esto:

preview with header sin boton Loading

Según el código anterior, el componente "sidebar" es dueño de los datos del usuario, pero ahora queremos compartirlo con el componente “header”.

Cuando construimos aplicaciones con React, muchas veces nos encontraremos con esta situación donde hay que compartir un dato entre componentes que tienen algún grado de parentesco (son hermanos, primos, etc.).

Para estos casos, la documentación de React nos recomienda utilizar la técnica de levantar el estado, la cual consiste en mover el estado al ancestro común más cercano, y luego entregarlo a través de props.

Para explicarlo voy a utilizar un ejemplo que involucra una casa con su puerta y una alarma. Pensemos que tenemos un componente “Casa” (House) y otro para “Puerta” (Door). La puerta tiene un estado que indica si está abierta o no llamado isDoorOpen:

Componente Door tiene el estado isDoorOpen y es hijo de House

graphLoading


¿Qué pasa si queremos agregar una alarma a la casa?

Bueno, podemos agregar la alarma como hermano de la puerta:

Se agrega componente alarma como hermano de Door

graphLoading

¿Y si queremos que la alarma sepa si la puerta está abierta? (para por ejemplo comenzar a sonar🚨)

Es ahí donde entra la técnica “levantar el estado” al ancestro en común. En este caso dicho ancestro es el componente House:

Movimos el estado isDoorOpen a House y lo pasamos como prop para abajo

graphLoading


¡Listo! Ahora Alarm y Door pueden saber si la puerta está abierta gracias a que esta información vive en el padre House quien se las comunica a través de props

Volviendo a nuestro ejemplo de "sidebar" y "header", podemos ver que pasa algo parecido:

Sidebar tiene el estado user que debe ser compartido con Header

graphLoading

En este caso, el ancestro común es el componente App, así que podemos mover el estado a este componente y luego distribuirlo a los interesados:

De esta forma podemos compartir un dato entre componentes.

Prop-drilling

En algunos casos tendremos componentes intermedios que toman el dato y lo pasan hacia abajo (técnica conocida como “prop drilling”):

Propiedad pasa desde la raíz componente a componente hasta llegar al final

graphLoading

En la imagen, el componente raíz envía una propiedad “💾” y luego cada componente la va pasando hacia abajo por props hasta que llega al nodo que la necesita.

En aplicaciones más grandes, nos encontraremos con que el ancestro en común se encuentra demasiado lejos, de manera que el dato recorrerá muchos componentes hasta llegar a su destino, muchos de los cuales solo “dan el pase” para que el dato siga su camino.

Usar “prop drilling” no es malo en sí mismo. El problema es cuando lo haces de manera profunda como en la imagen anterior, ya que aparte de que el pobre dato llega cansado, al momento de que tengas que hacerle una refactorización (por ejemplo, cuando actualizas el dato de número a objeto) tendrás que cambiar muchos componentes. Para estos casos, React nos entrega una herramienta muy útil llamada Context.

Context te permite inyectar un dato de un componente padre a algún descendiente de manera directa, sin necesidad de pasarlo componente a componente:

Propiedad pasa directamente desde la raíz hasta el componente final

graphLoading

Podríamos decir que Context “teletransporta” el dato desde quien lo provee hasta el componente que lo consume.

En lo que queda del artículo te voy a explicar cómo utilizar Context. Por ahora, quiero que recuerdes que hay 3 formas de compartir un estado:

  • Pasarlo de un padre a hijo como propiedad (props)
  • Cuando hay dos componentes que lo requieren (y son primos, hermanos, etc.) podemos mover el estado a un ancestro en común (subir el estado) transmitirlo desde arriba hacia abajo muchas veces con “prop drilling” (que es cuando un componente toma un dato y lo pasa a un componente hijo).
  • Utilizar Context para inyectar directamente un dato desde un ancestro en común a uno de sus descendientes.

¿Qué es Context?

Muchas veces que hablamos de Context, lo primero que se nos viene a la mente es que sirve para “manejar” estados globales (es por eso por lo que hay muchas preguntas sobre “Context vs. Redux” en comunidades de programación).

Pero si nos vamos a la documentación oficial

Context provee una forma de pasar datos a través del árbol de componentes sin tener que pasar props manualmente en cada nivel.

Es decir, la gracia de Context tiene que ver con pasar datos de una forma directa, y se usa para compartir datos que pueden considerarse “globales” para un árbol de componentes en React.

¿Es posible manejar estado con Context?

“Compartir estado” es diferente a “manejar estado”. Cuando hablamos de “manejar estado” nos referimos esencialmente a tres cosas:

  1. Poder guardar un valor inicial.
  2. Poder leer el valor actual.
  3. Poder actualizar el valor.

Context nos puede ayudar a lograr estos objetivos, pero no es suficiente, ya que Context por sí mismo no tiene un estado.

Para lograr manejar estado tendremos que utilizar Context junto a useState o useReducer. Más adelante en este artículo veremos cómo lograrlo.

¿Cómo utilizamos Context?

Para utilizar Context, hay 3 elementos importantes que debemos tener en cuenta:

  • La función createContext que se encarga de crear el context.
  • El componente Provider que es el encargado de decidir qué dato transmitir. Debemos usarlo en el componente que tiene el dato a distribuir.
  • El componente Consumer que es el encargado de obtener el dato que está siendo transmitido por el Provider (alternativamente podemos utilizar el hook useContext). Debemos usarlo en el componente que quiere leer el dato.

Te va a quedar más claro luego de ver los 3 pasos para utilizarlo.

Paso 1: Crear nuestro context

El primer paso es crear el context utilizando la primera función:

La función createContext nos entrega un objeto con dos propiedades: Provider y Consumer.

Por convención, solemos nombrar a este objeto como Nombre + Context, donde Nombre está relacionado con la intención del dato. Por ejemplo, si queremos distribuir la información de un usuario podemos nombrarlo User, para el lenguaje de la aplicación puede ser Language y para un tema podría ser Theme.

En nuestro ejemplo, la información a compartir es el Usuario, por ende, lo nombremos como UserContext

Una vez ya creado este Context, ¿cómo lo usamos?

Tenemos que usar las propiedades Provider y Consumer donde corresponde.

El Provider se usa en el componente que tiene la pieza de estado que queremos compartir, mientras que el Consumer debemos utilizarlo en el componente que necesita el dato (de ahí los nombres de Provider → proveedor y Consumer → consumidor).

Paso 2: Utilizar el Provider para pasar el dato

El componente Provider nos pide una propiedad llamada value que corresponde al dato que queremos distribuir. Así que nos vamos al componente que tiene la pieza de estado, y modificamos su retorno para incorporar el Provider.

En nuestro ejemplo, el componente App tiene la pieza de estado que queremos compartir. Así que modificamos la función encerrando todo lo que usa la propiedad user con el componente UserContext.Provider

Notar que eliminamos la propiedad user de Header y de Sidebar, pues ahora la pasaremos a través de UserContext.

Ahora cualquier descendiente de App podrá leer los datos actualizados del usuario. ¿Cómo? A través del “consumidor”.

Paso 3: Utilizar el Consumer para leer el dato

Usando Consumer

Lo primero que tenemos que hacer es identificar que componentes van a consumir la información. En nuestro ejemplo, tenemos dos consumidores: Header y Sidebar (ambos necesitan acceder a los datos del usuario).

Cuando usamos Context, el Consumer utiliza la técnica render props para leer el dato. Así que nuestro Header pasa de:

A esto:

Como puedes ver, el hijo directo de UserContext.Consumer es una función que recibe como primer parámetro el user (que es el dato transmitido por el UserContext.Provider) y que retorna el elemento que mostraremos en la pantalla (<div class="Header">...</div>)

El código actualizado del Sidebar sigue la misma lógica:

Usando useContext

La sintaxis de “render props” puede parecer un poco confusa y a veces es difícil de leer. Lo bueno es que existe un hook llamado useContext que nos permite ahorrarnos utilizar a Consumer directamente y que deja nuestro código más limpio.

useContext recibe como parámetro el contexto (el resultado de React.createContext) y retorna el valor actual del contexto.

Mira como quedan estos componentes utilizando el hook useContext (ver código en Codesandbox)

Mucho más parecido a lo que teníamos originalmente.

¿Cuándo usar Consumer y cuándo usar useContext?

Mi recomendación es que uses el hook useContext porque simplifica un montón todo😄, aunque en componentes de clases tendrás que usar Consumer directamente.

De lo que llevamos hasta ahora te tengo dos observaciones adicionales:

Observación #1: Context transmite un solo dato

Notarás que según la API de Context, el dato que viaja es solo uno.

Si queremos pasar múltiples valores hacia abajo, tenemos que pasar un objeto o un arreglo que los contenga:

Observación #2: Context no tiene estado

Como te mencione anteriormente, Context por sí mismo no tiene estado, es solo un mecanismo para distribuir un valor. Sin embargo, hay una técnica que podemos usar para darle superpoderes de manejo de estado (que veremos a continuación).

Cómo utilizar Context para manejo de estado

Sabemos que el manejo de estado lo logramos cuando podemos guardar un valor inicial, cuando podemos leerlo y actualizarlo. También sabemos que Context nos ayuda a distribuir información. La pieza restante está en las raíces de React: state.

Siguiendo con nuestro ejemplo, ahora agregaremos el botón para randomizar las letras mayúsculas y minúsculas del username como se muestra a continuación:

Muestra como el botón randomizar cambia muzk a MUZK

demoLoading

Antes de poder actualizar el nombre de usuario desde el Sidebar, vamos a actualizarlo directamente desde el componente App, que es el encargado de sostener la información del usuario y donde va a ser más fácil hacerlo 😅

Para randomizar un string, podemos usar la siguiente función:

Para actualizar el username, crearemos una función llamada updateUsername dentro del componente App, y que se encargará de actualizar solo el nombre de usuario:

Utilizando estas dos funciones, podemos actualizar el código de App de la siguiente forma:

Visualmente tenemos esto:

randomLoading

La lógica implementada está funcionando, pero todavía nos falta mover el botón al Sidebar (ahí donde quedó no se ve bien😂)

¿Cómo movemos el botón al Sidebar? Una forma de hacerlo es:

  • Actualizamos la propiedad value de UserProvider para que ahora sean dos valores: los datos del usuario (user) y la función para actualizar el username (updateUsername).
  • Movemos el elemento button al Sidebar
  • Actualizamos la lectura del contexto considerando que ahora vendrán dos valores.

Ok, manos a la obra.

Componente App:

Actualizamos el valor del provider para pasar dos valores a través un objeto con dos propiedades:

Componente Header:

Ahora refactorizamos los consumers para esta nueva realidad donde el valor del contexto es un objeto con dos propiedades:

Componente Sidebar:

En el caso del Sidebar, necesitaremos ambos valores:

y además tenemos que agregar el botón para actualizar el username. Finalmente nos queda así:

El resultado:

Al presionar randomizar, se actualiza el nombre de usuario en el sidebar y en el header

demoLoading

Como puedes ver, gracias a Context, pudimos pasar el dato junto a una forma de actualizarlo. De esa manera, distribuimos el dato y una forma de actualizar la información de manera sencilla y directa (código actualizado), logrando manejar el estado.

Todo esto lo hicimos mezclando las herramientas que nos provee React. Si bien este ejemplo es de juguete, en aplicaciones reales el uso es el mismo.

A continuación, quiero ir al siguiente nivel y presentarte una forma más ordenada de gestionar estados con Context.

Extrayendo la lógica del estado al Provider

Hasta ahora, App se encarga de manejar la lógica de los datos del usuario y además de renderizar todo el árbol de la aplicación.

En ocasiones queremos evitar esto, sobre todo cuando vamos agregando más piezas de estado por las cuales preocuparnos. Una opción es extraer toda la lógica asociada a un estado a un componente diferente que se encargue solo de eso.

En nuestro ejemplo, podríamos crear un componente que se llame UserProvider, quien se encargará de guardar el estado y de distribuirlo a través de Context de manera similar a como lo hacía App:

Como la lógica está ahí, ahora App nos queda mucho más limpio:

App volvió a ser un stateless component (un componente sin estado) y además solo se preocupa de renderizar el árbol de nuestra App. Por otro lado, UserProvider es todo sobre el manejo de estado relacionado con el usuario. De esta forma el código quedo más limpio y pudimos separar responsabilidades de los componentes.

Puedes ver la versión final del código aquí.

Precauciones de renderizado con Context

Hasta ahora te omití un punto muy importante que indican en la documentación oficial de Context:

Todos los consumidores que son descendientes de un Provider se vuelven a renderizar cada vez que cambia la prop value del Provider

Cuando un componente está suscrito a un Provider (ya sea a través del componente Consumer o useContext, se volverá a renderizar cuando haya un cambio en el value del Provider (el cambio se detecta con el algoritmo de Object.is)


Por lo tanto, tenemos que ser cuidadosos en el valor que pasamos.

Mira el siguiente ejemplo:

Aquí value cambia cada vez que este Provider se renderiza, incluso si firstNumber o secondNumber no han cambiado. Esto ocurre porque se está instanciando un nuevo objeto ({firstNumber, secondNumber}) en cada render, lo que será detectado como un cambio y terminará notificando a todos los componentes que estén suscritos (vía Consumer o useContext).

En muchas ocasiones estos renderizados extras no serán problemáticos. Pero imagina que los consumidores cada vez que se renderizan hacen un llamado a API o alguna otra operación costosa, eso podría repercutir en la rendimiento de tu aplicación y finalmente afectar la experiencia del usuario.

Aquí te presento un ejemplo de este problema (ver en Codesandbox):

Sí… es un poco largo, pero ya te lo explicaré.

El corazón del código es el componente NumbersProvider, que tiene 3 variables de estado: firstNumber, secondNumber y twoSecondsElapsed. Además, a través de useEffect y setTimeout:

  • Luego de un segundo el valor de firstNumber pasa de 0 a 1
  • Luego de dos segundos cambiamos el valor de twoSecondsElapsed
  • Finalmente, luego de tres segundos actualizamos el valor de secondNumber a 2.

Cada uno de estos puntos hace que NumbersProvider se vuelva a renderizar (porque estamos actualizando las variables de estado).

Si vemos el output en consola de ese programa veremos lo siguiente:

En el tercer renderizado se vuelven a imprimir los consumidores incluso si los números se mantienen igual

renderingLoading

A pesar de que firstNumber y secondNumber no cambiaron en el render #3, los consumidores se volvieron a renderizar debido a que value siempre es un objeto nuevo.

En cambio, si usamos useMemo podemos evitar el renderizado extra (ver ejemplo en Codesandbox):

¿Funcionará? Pues claro, aquí va el output actualizado:

En el tercer renderizado, los consumers no se actualizan.

renderingLoading

Esto funciona porque useMemo entregará un nuevo objeto si y solo si firstNumber o secondNumber cambian.

Como te mencioné antes, los renderizados innecesarios no siempre son un problema. Incluso, me atrevo a decir que en ocasiones es contraproducente optimizarlos. Sin embargo, si estás usando Context y crees tener problemas de performance, te recomiendo revisar si es debido a esto.

Recapitulación

En este artículo vimos algunas técnicas para compartir datos entre componentes: levantar el estado (mover el estado a un ancestro en común), prop drilling (cuando un componente recibe un prop y se la pasa a un componente hijo) y finalmente Context (nos permite inyectar un valor desde un ancestro directamente a uno de sus descendientes sin intermediarios).

También argumentamos que Context por sí mismo no tiene estado, y que debemos complementarlo con el manejo de estado clásico de React (useState o useReducer) para lograr manejo de estado.

Finalmente. vimos una forma en la cual podemos lograr gestionar el estado de manera global con Context y useState. Encapsulamos el funcionamiento de Context con un componente que también gestiona el estado para dejar el código más limpio y encapsulado.

Como recomendación final, debemos tener cuidado con el valor que pasamos en el Provider, pues esto puede tener como consecuencia renderizados innecesarios, lo cual puede desembocar en problemas de rendimiento en nuestras aplicaciones.