Comunicación entre componentes
Hasta el momento, la única forma de pasar datos entre componentes que hemos visto es a través de las props. Esto quiere decir que un componente padre puede pasar datos a un componente hijo enviándolos explícitamente a través de los atributos del componente hijo. Por ejemplo:
1function Child(props) {2 return <p>{props.text}</p>;3}4
5function Parent() {6 return <Child text="Hello world" />;7}
No obstante, si tenemos una estructura de componentes más compleja que debe mantener cierta comunicación entre ellos, veremos que al emplear este mecanismo producirá dos grandes inconvenientes:
-
Anidamiento excesivo: si tenemos una jerarquía de componentes muy profunda, la propagación de las props a través de todos los componentes puede resultar en un código difícil de leer y mantener. En muchas ocasiones, los componentes intermedios no necesitan los datos que se les pasan, sino que simplemente los reenvían a sus componentes hijos.
1function Grandchild(props) {2return <p>{props.text}</p>;3}45function Child(props) {6return <Grandchild text={props.text} />;7}89function Parent() {10return <Child text="Hello world" />;11} -
Dependencia entre componentes: en una aplicación real, necesitaremos pasar datos entre componentes que no tienen una relación de padre-hijo, como por ejemplo, entre dos componentes hermanos. Si estos requieren de un estado compartido o de una prop, se producirá una relación de dependencia hacia arriba en la jerarquía de componentes.
1function Child(props) {2return <p>{props.text}</p>;3}45function OlderChild(props) {6return <p><strong>{props.text}</strong></p>;7}89function Parent() {10const [text, setText] = useState("Hello world");11return (12<>13<Child text={text} />14<OlderChild text={text} />15</>16);17}
Cuando un estado o una prop se encuentra tan elevado en la jerarquía de componentes, puede ocasionar una situación que se conoce como prop drilling y es una desventaja inherente al uso de las props para pasar datos entre componentes.
En definitiva, necesitamos una forma de pasar datos entre componentes de una forma más eficiente y flexible. Para ello, React nos provee de un mecanismo denominado contexto.
Contexto
El contexto es una característica que permite que un componente pase datos a todos sus descendientes, sin importar cuántos niveles de anidamiento haya entre ellos. Todos los componentes que descienden de un componente que provee contexto podrán suscribirse a él y solicitar los datos que necesiten.
Crear un contexto
El primer paso a seguir será crear el contexto. Para ello, utilizaremos la función createContext
de React.
1import { createContext } from 'react';2
3const MyContext = createContext();
Proveer un contexto
Una vez creado el contexto, necesitamos un componente que provea los datos que queremos compartir. Para ello, debemos envolver a los componentes que descienden de él con el componente Provider
, el cual nos proporciona el mismo contexto que hemos creado con createContext
.
Para indicar qué datos queremos compartir, debemos pasarlos como un atributo al componente Provider
a través de la prop value
, la cual acepta cualquier tipo de dato.
1<MyContext.Provider value={data}>2 {children}3</MyContext.Provider>
Consumir un contexto
Finalmente, para que los componentes descendientes puedan acceder a los datos del contexto, necesitamos consumir el contexto en ellos. Para ello, tenemos dos alternativas:
-
Hook
useContext
: es un hook que nos permite acceder al valor del contexto en cualquier componente funcional.1import { useContext } from 'react';2import MyContext from './MyContext';34function ComponentX() {5const data = useContext(MyContext);67return <p>{data}</p>;8} -
Componente
Consumer
: es un componente que nos permite acceder al valor del contexto en cualquier componente de clase.1import { Component } from 'react';2import MyContext from './MyContext';34class ComponentX extends Component {5render() {6return (7<MyContext.Consumer>8{data => <p>{data}</p>}9</MyContext.Consumer>10);11}12}
Valor por defecto
Si React no puede encontrar un proveedor del contexto en particular en el árbol padre, el valor del contexto devuelto por useContext
o Consumer
será el valor que se haya pasado como argumento a createContext
.
1const MyContext = createContext("Hello world");
En otras palabras, si no se provee un valor a través del componente Provider
, el valor por defecto del contexto será el que se haya pasado a createContext
. En este ejemplo, el valor del contexto será "Hello world"
.
Sobreescritura de contextos
Dado que el contexto permite acceder a los datos de un componente ancestro, es posible volver a declarar un contexto en un componente descendiente. Esto es útil cuando queremos modificar los datos del contexto en un componente específico sin afectar a los demás componentes.
Para ello, debemos utilizar el componente Provider
del contexto en cuestión y pasarle los nuevos datos a través de la prop value
.
1function Parent() {2 const [data, setData] = useState("Hello world");3
4 return (5 <MyContext.Provider value={data}>6 <Child />7 </MyContext.Provider>8 );9}10
11function Child() {12 const data = useContext(MyContext);13
14 return (15 <>16 <p>{data}</p>17 <MyContext.Provider value="Hello universe">18 <Grandchild />19 </MyContext.Provider>20 </>21 );22}23
24function Grandchild() {25 const data = useContext(MyContext);26
27 return <p>{data}</p>;28}
En este ejemplo, el componente Child
accede a los datos del contexto y los muestra en un párrafo. Luego, redeclara el contexto con un nuevo valor y envuelve al componente Grandchild
con el componente Provider
para que este pueda acceder a los nuevos datos.
De esta manera, podemos establecer un flujo de datos específico para un subárbol de componentes sin afectar a los demás componentes.
Casos de uso
Existen mecanismos que tradicionalmente suelen gestionarse mediante el uso de contextos, como por ejemplo:
- Internacionalización: para cambiar el idioma de la aplicación.
- Tema: si nuestra aplicación permite a los usuarios cambiar la apariencia de la interfaz, como por ejemplo, el color de fondo o el tamaño de la fuente.
- Autenticación: para gestionar el estado de autenticación del usuario. En este sentido, podemos gestionar al usuario que actualmente ha iniciado sesión, así como también las credenciales de acceso de una forma centralizada.
- Enrutamiento: la mayoría de las soluciones al problema de la navegación en una aplicación web se basan en el uso de contextos. Por ejemplo, para gestionar la ruta actual de la aplicación o para gestionar la navegación entre diferentes pantallas. Por supuesto, existen librerías como
react-router
que nos facilitan esta tarea. - Gestión de estados complejos: en aplicaciones grandes, es común que existan estados que deben ser compartidos entre diferentes componentes. En estos casos, el uso de contextos nos permite gestionar estos estados de una forma más eficiente.
El Patrón de Proveedor de Contexto
Formalmente, el mecanismo que hemos analizado se conoce como el Patrón de Proveedor de Contexto. Este patrón de diseño aprovecha la API de contexto de React para crear una forma estructurada de gestionar y pasar datos a través de tu árbol de componentes. Como hemos visto, ayuda a evitar el prop drilling, donde es necesario pasar datos a través de múltiples capas de componentes, incluso si algunos de ellos no necesitan los datos.
Hasta el momento, hemos visto que este patrón se compone de tres elementos:
- Contexto: es un objeto que React crea y que se utiliza para compartir datos entre componentes.
- Proveedor de Contexto: es un componente que envuelve a los componentes que descienden de él y que les proporciona el contexto.
- Consumir Contexto: actualmente, hemos visto dos formas de consumir un contexto:
- Mediante un Consumidor de Contexto, que es un componente que permite a los componentes descendientes acceder a los datos del contexto.
- Mediante un hook, como
useContext
, que es una función que nos permite acceder al valor del contexto en cualquier componente funcional.
Si bien hemos logrado una comunicación más eficiente entre componentes en los ejemplos anteriores, todavía existen algunas limitaciones en el uso de contextos. Por lo cual, veremos algunas recomendaciones y buenas prácticas para cada uno de los elementos del patrón de proveedor de contexto.
Contexto
-
Responsabilidad única: un contexto debe tener una única responsabilidad. Es decir, no debemos intentar abarcar múltiples funcionalidades en un solo contexto. En su lugar, es preferible crear múltiples contextos que se encarguen de diferentes aspectos de la aplicación. Por ejemplo, un contexto para la autenticación, otro para el tema y otro para el idioma.
-
Datos inmutables: los datos que se pasan a través de un contexto deben ser inmutables. Esto significa que no debemos modificar directamente los datos del contexto, sino que debemos crear una copia de los mismos y modificar la copia. De esta forma, garantizamos que los componentes consumidores se vuelvan a renderizar correctamente. Esto puede lograrse mediante el uso de estados, ya sea con
useState
o conuseReducer
. -
Valores por defecto: a menudo, la naturaleza de los datos y la lógica de un contexto pueden ser complejas. Por lo que, es común encontrar implementaciones de contextos que incluyen reductores para gestionarlos. Siendo este un enfoque más avanzado, es recomendable proporcionar como valor por defecto un objeto que contenga:
- El estado del contexto.
- Las acciones que modifican el estado del contexto.
1import { createContext } from 'react';2const MyContext = createContext({3state: {},4actions: {}5});La finalidad de este enfoque es proporcionar un esquema compatible con el uso de reductores, lo que facilita la gestión de estados complejos.
Proveedor de Contexto
La función createContext
de React nos permite crear un contexto, el cual nos proporciona un objeto que contiene un Proveedor de Contexto. Sin embargo, este proveedor no es más que un componente de React que envuelve a los componentes descendientes y siempre debemos configurarlo según nuestras necesidades. Es decir, siempre se delega la responsabilidad de configurar el contexto a los componentes que lo proveen, típicamente en el componente raíz de la aplicación.
En este sentido, es una práctica recomendable crear un Proveedor de Contexto personalizado que encapsule la lógica de configuración del contexto y que nos permita reutilizarlo en diferentes partes de nuestra aplicación. De esta forma, este proceso de configuración no se verá disperso en diferentes partes de la aplicación, sino que estará centralizado en un solo lugar.
Para definir un proveedor de contexto personalizado, se establece una convención sobre cómo se debe configurar el contexto y se proporciona un componente que envuelve a los componentes descendientes.
- Este componente debe aceptar una prop
children
que representará a los componentes descendientes que se envolverán y podrán acceder al contexto. - El nombre del componente debe seguir la convención de UpperCamelCase e incluir el sufijo
Provider
. - El valor del contexto se establece en base a las acciones y al estado del contexto definidos al momento de crearlo.
1import { useState } from 'react';2import MyContext from './MyContextPath';3
4function MyContextProvider({ children }) {5 const [state, setState] = useState({});6 const value = {7 state: { state },8 actions: { setState }9 };10
11 return (12 <MyContext.Provider value={value}>13 {children}14 </MyContext.Provider>15 );16}
Consumir Contexto
Este fue el aspecto que más exploramos hasta el momento. Pero, ¿cómo podemos consumir un contexto de una forma más eficiente y flexible?
Si bien, React nos permite consumir un contexto de manera sencillo mediante el uso de useContext
o Consumer
, existen situaciones en las que necesitamos un comportamiento más específico.
Si hablamos de componentes funcionales, el hook useContext
es la forma más sencilla de consumir un contexto. Sin embargo, quienes consumen un contexto no siempre necesitan acceder a todos los datos del mismo, sobre todo si estos son complejos. En estos casos, es recomendable proporcionar un hook personalizado que encapsule la lógica de consumo del contexto y que permita a los componentes consumidores acceder a los datos de una forma más específica.
Por ejemplo, si contamos con un contexto para la autenticación, es posible que necesitemos un hook que nos permita acceder al usuario autenticado o a las credenciales de acceso. En este caso, podríamos crear un hook personalizado que nos permita acceder a estos datos en particular.
1import { useContext } from 'react';2
3function useAuth(type) {4 const { state, actions } = useContext(AuthContext);5
6 switch (type) {7 case 'user':8 return state.user;9 case 'token':10 return state.token;11 default:12 return null;13 }14}
De igual forma, podríamos aplicar este enfoque a las acciones que modifican el estado del contexto. Por ejemplo, en el contexto de la autenticación, podríamos crear un hook personalizado que nos permita acceder a las acciones de inicio de sesión, cierre de sesión, etc.
1import { useContext } from 'react';2
3function useAuthActions(type) {4 const { state, actions } = useContext(AuthContext);5
6 switch (type) {7 case 'login':8 return actions.login;9 case 'logout':10 return actions.logout;11 default:12 return null;13 }14}
De esta forma, podemos proporcionar una interfaz más específica y clara para los componentes consumidores, lo que facilita su uso y mantenimiento.