Sobre Observables, Performance y Magia Negra en React con Context y Hooks
Aprovechando el impulso tras escribir mi último artículo sobre Manejo de estado con Context y Hooks en React, me animé a hablar al respecto y participé en la edición de Septiembre del meetup de Noders. Mientras preparaba el material para mi presentación (que puedes ver por YouTube), llegué a este comentario en los issues del repositorio de React:
O en español:
Mi resumen personal es que el nuevo context está listo para ser utilizado para actualizaciones poco frecuentes (como un locale/tema). También es bueno utilizarlo de la misma manera que el viejo context era utilizado, por ejemplo, para valores estáticos que luego son propagados a través de suscripciones. No está listo para ser utilizado como reemplazo de un sistema de propagación de estado estilo Flux.
Sebastian Markbåge
Esto llamó de forma inmediata mi atención, especialmente cuando leí la palabra suscripciones. Lo primero que se me vino a la cabeza fue programación reactiva y si me conoces un poco, sabrás que soy muy fanático de este paradigma (no por nada he escrito no sólo uno, ni dos sino tres artículos al respecto).
Pues bien, en esta ocasión vamos a explorar la utilización de algunas técnicas de programación reactiva junto con Context y Hooks en React para comunicación de data centralizada.
Para efectos de este artículo voy a asumir cierta familiaridad con la API de Context y hooks, por lo que asegúrate de leer el artículo anterior en caso de que tengas alguna duda conceptual.
Pero antes, un poco de contexto…
Para entender por qué es buena idea hacer propagación de data a través de observables y suscripciones al utilizar Context, primero tenemos que entender qué sucede al actualizar la data provista en un Context.Provider
.
Consideremos el siguiente ejemplo:
El valor que le pasamos a la prop value
de Timer.Provider
se actualiza cada un segundo. Utilizando el Profiler (al que puedes acceder instalando React DevTools para tu navegador favorito) podemos visualizar cómo React renderiza nuestra aplicación mientras cambia el valor que le hemos pasado a nuestro Context:
En el primer commit que realiza React todos los componentes son renderizados, como es de esperarse.
Antes de proseguir y para que estemos todos en la misma página, aclaremos un par de conceptos. Conceptualmente, React realiza trabajo en dos fases:
- La fase de render determina qué cambios se deben efectuar, por ejemplo, en el DOM. Durante esta fase, React llama a
render
en nuestros componentes y compara los cambios entre el resultado del render actual y el previo. - La fase de commit es cuando React aplica cualquier cambio que se halla identificado entre cada render. Cuando usamos React en el browser con ReactDOM, esto implica insertar, actualizar, eliminar y realizar operaciones sobre los nodos del DOM.
Dicho eso, podemos ver que para los commits siguientes, React sólo va a aplicar actualizaciones en 3 de nuestros 4 componentes:
TimerProvider
se renderiza tras cada commit porque el estado interno del componente, en este casotime
, es actualizado en el callback que le hemos pasado asetInterval
dentro de nuestrouseEffect
.- Luego,
Context.Provider
se renderiza básicamente porque su padre se ha renderizado. App
no se renderiza: en el Profiler los componentes que no se renderizan salen de color gris.- Finalmente, es el turno de
TimerConsumer
de ser renderizado, debido a que el valor provisto por el contexto que estamos consumiendo conuseContext
ha cambiado.
En este caso hemos visto cómo la actualización en el estado de un componente ha generado actualizaciones en sus hijos. Lo notorio de este ejemplo es que el componente App
, que no tiene conocimiento alguno sobre la existencia de TimerProvider
, nunca se vuelve a renderizar por causa de las actualizaciones en el Contexto.
La raíz de todo render
Sólo para persistir esta idea en nuestras cabezas, volvamos a mencionar por qué razón TimerProvider
se renderiza:
TimerProvider
se renderiza tras cada commit porque el estado interno del componente, en este casotime
, es actualizado en el callback que le hemos pasado asetInterval
dentro de nuestrouseEffect
.
Cuando utilizamos Context nuestro objetivo es propagar data a todos los componentes que consumen el contexto, y para iniciar este proceso lo único que podemos hacer es generar cualquier cambio de estado en nuestro proveedor de contexto. ¿Qué significa esto? Pues en estricto rigor significa que cualquier cambio en la data que le entregamos a Context.Provider
va a generar un nuevo render en todos nuestros consumidores, incluso en aquellos que no consumen el pedazo de data que ha cambiado.
En la gran mayoría de los casos, estos renders adicionales no generan mayor problema a nivel de performance. Sin embargo para los casos en los que sí se generen problemas, sería bueno tener a la mano alguna alternativa viable.
Pongámonos a pensar un momento, ¿Qué pasaría si hipotéticamente logramos aislar las fuentes de cambio dentro de nuestro proveedor de Context? O re-formulando la pregunta ¿Es posible exponer data actualizada a nuestros consumidores evitando que TimerProvider
se vuelva a renderizar?
La única forma en la que podemos impedir que TimerProvider
se renderice, es que el estado interno del componente NUNCA se actualice.
La solución más naive (inocente) sería extraer la variable time
de TimerProvider
y acceder a ella a través de un closure:
Si revisamos nuevamente nuestra aplicación utilizando el Profiler, podemos ver lo siguiente:
Lamentablemente nuestro experimento no funcionó. La aplicación se ha renderizado sólo una vez. Si bien el valor de time
se actualiza dentro del efecto, debemos decirle explícitamente a React que el estado de la aplicación ha cambiado. Una vez que eso suceda, React internamente va a comparar el render previo con el actual y generar un commit. Para demostrar esto, podríamos tener otro efecto que cambie el valor de un flag declarado con useState
después de 2 segundos de haberse montado TimerProvider
:
Si miramos lo que ha registrado el profiler, nos damos cuenta que la aplicación se renderiza sólo dos veces:
La segunda vez que lo hace, el valor que recibe TimerConsumer
a través de useContext
es 2, pero el render no sucede en virtud del cambio que tuvo el valor que le pasamos a Context.Provider
, pues ya había cambiado antes y no provocó nada como pudimos ver.
El cambio en el valor de toggled
es suficiente para hacer que nuestro TimerProvider
se renderice, renderizando a Context.Provider
como consecuencia, incluso si no le estemos pasando toggled
a nuestro Context.Provider
. Como ahora efectivamente el valor expuesto por nuestro contexto es distinto, los consumidores son notificados y se vuelven a renderizar con el valor actual.
Si algo podemos concluir sobre este experimento, es que DEBEMOS registrar el cambio de estado en algún lugar de nuestro árbol de componentes; y si hemos decidido deliberadamente que no lo vamos a hacer dentro TimerProvider
, nuestra única opción es hacerlo dentro de TimerConsumer
.
¿Pero cómo implementaríamos semejante solución?
Súper Vaca al rescate 🐄
A esta altura ya no debería ser sorpresa cuál es la solución. Está en el título de artículo y lo mencioné al comienzo del mismo: Patrón del Observador, programación reactiva y…
La gracia de utilizar el Patrón del Observador, es que podemos generar una estructura de datos (llamada sujeto) con la capacidad de almacenar un pedazo de estado internamente, es decir fuera de React, y actualizar dicho estado sin incurrir en un render. Como la referencia al sujeto nunca cambia una vez que ya lo hemos creado, nuestros proveedor de contexto no será renderizado innecesariamente.
Para notificar a nuestros consumidores que la data del sujeto se ha actualizado, estos deben suscribirse explícitamente al último.
Si te interesa saber más sobre el patrón del observador, puedes leer mi artículo Programación Reactiva en JavaScript.
Dicho eso, nuestra implementación debería tener las siguientes características. Primero, creamos de alguna forma (con Magia) nuestro sujeto y lo exponemos a través de Context.Provider
. Nuestro sujeto debe permitirnos observar los cambios en su estado local a través de alguna API:
Luego, dentro de nuestro consumidor del contexto, debemos observar los cambios en el estado interno de timer$
y guardar una referencia local a ese valor. Esto último es porque debemos indicarle a React que es necesario renderizar nuestro componente con la data actualizada, si no nunca vamos a ver esos cambios reflejados. Para estos efectos, timer$
expone un método llamado observe
:
De igual manera, deberíamos ser capaces de cancelar la suscripción cuando nuestro consumidor se desmonta para evitar fugas de memoria:
Actualmente si queremos suscribirnos a timer$
en algún otro componente, deberíamos implementar la misma lógica lo que puede resultar muy molesto y propenso a bugs 🐛.
Afortunadamente podemos abstraer esta lógica utilizando más magia en forma de un custom hook (aunque también es posible hacerlo con Higher Order Components):
Y luego consumimos nuestro contexto observable en todos los lugares que queramos.
Hasta ahora está todo perfecto salvo por un detalle no menor. Aún no hemos implementado nuestro sujeto en Timer.Provider
:
Para esto crearemos una función llamada interval
que debe cumplir con los siguientes requerimientos:
- Aumentar un contador desde cero cada cierto intervalo de tiempo.
- Registrar observadores que serán notificados cada vez que cambie el valor del contador. Recordando nuestro ejemplo al comienzo del artículo utilizando
TimerProvider
, el valor del contador debe estar sincronizado para todos los consumidores de nuestro contexto. - Como vimos en la implementación de
useObservableState
, debemos terminar la suscripción cuando nuestro componente consumidor se desmonte. De no ser así, tendremos una fuga de memoria ya que estaríamos intentando llamar asetValue
en un componente desmontado. Por lo mismo debemos ser capaces de eliminar un observador cuando sea pertinente (en la función de cleanup de nuestrouseEffect
). - Finalmente debemos detener nuestro
setInterval
cuandoTimerProvider
se desmonte.
Nuestra función interval podría ser implementada así:
Finalmente actualizamos TimerProvider
con nuestra nueva magia:
La razón por la que debemos llamar a interval
dentro de useMemo
, es para que se ejecute sólo una vez y no en cada render. De ejecutar interval
en cada render nuestra aplicación no tardaría en explotar 💥 (Gracias a matias trujillo por el comentario, inicialmente invocaba interval
dentro de un useRef
).
Puedes ver un demo acá.
Antes de continuar perfilando nuestra solución, quiero hacer hincapié en que al utilizar programación reactiva, sujetos, observables, suscripciones y etcétera, podemos deferir la ejecución de nuestra lógica de negocio hasta que sea estrictamente necesario hacerlo — para nuestro ejemplo esto corresponde a actualizar un contador periódicamente a través del tiempo.
En la implementación de
interval
esto no es tan aparente, porque hemos modelado nuestra función para que refleje la implementación inicial conuseState
yuseEffect
, pero perfectamente podríamos hacer que el contador fuera lazy, o que en otras palabras elsetInterval
no se ejecutara hasta haber registrado el primer observador.
Dicho eso, volvamos a ver qué sucede con los commits en nuestra aplicación con el Profiler.
En el primer commit no hay muchos cambios aparentes pues toda la aplicación debe renderizarse. La duración del render en la aplicación con estado observable es ligeramente mayor (pero negligible a esta escala).
A partir del segundo commit comenzamos a notar diferencias:
¡Sólo
TimerConsumer
se ha actualizado! ¡Increíble!
Poniendo las cosas en escala
No vale la pena revisar el resto de los commits puesto que en todos sucede lo mismo: sólo TimerConsumer
se renderiza y por lo que ya sabemos, es debido a que guardamos el valor del observable cada vez que se actualiza dentro de nuestro custom hook useObservableState
.
De igual forma, a esta escala la diferencia de performance es negligible y sería irresponsable sacar conclusiones apresuradas, por lo que llevaremos esto un paso más adelante: en vez de consumir nuestro contexto en un (1) solo componente, lo haremos en cien (100) componentes.
Para esto lo único que cambiaremos en nuestra aplicación es lo siguiente:
const nodes = Array(100)
.fill("")
.map((_, index) => `id_${index}`);const App = () => {
return nodes.map(id => <TimerConsumer key={id} />);
};
Y voilá, vamos a renderizar cien veces TimerConsumer
. Veamos qué sucede ahora al perfilar nuevamente nuestra aplicación.
Primero, utilizando nuestro TimerProvider
común y corriente:
Podemos apreciar que se ejecuta un commit cada segundo (viendo el campo Commited at
bajo la información del commit a mano derecha). Esto tiene mucho sentido porque el valor de la variable count
en TimerProvider
como ya explicamos se actualiza cada 1 segundo, gatillando a su vez que los cien componentes se rendericen en el mismo commit.
Veamos ahora qué sucede al perfilar nuestra aplicación usando nuestro contexto observable:
Debo admitir que la primera vez que me encontré con esto me llevé una enorme sorpresa, pero que no cunda el pánico y veamos qué ha sucedido realmente.
Como cada componente consumidor invoca al hook useObservableState
, el estado se persiste en CADA UNO DE ELLOS en la variable count
, por ende, React compara el render anterior con el nuevo render para cada uno de los componentes. Como el valor de count
ha cambiado, decide que es necesario hacer un commit. Al haber terminado con el primer consumidor y siguiendo con la misma lógica, decide que debe renderizar el segundo componente, luego el tercero, luego el cuarto y así para cada uno de nuestros cien TimerConsumer
s, hasta llegar al commit número 102, que es cuando se renderiza el último de ellos.
En el commit número 103 se renderiza nuevamente el primero de nuestros cien TimerConsumer
s, y es debido a que el observable timer$
expuesto por nuestro TimerProvider
emite un nuevo valor.
Déjenme decirles de antemano: cien mutaciones independientes en el DOM que se deben realizar en menos de 16ms (para que se vea a 60fps) no es tanto trabajo para el navegador, como nos han hecho creer. Los browser saben manejar muy bien modificaciones en el DOM y son increíblemente rápidos haciéndolo. Sin embargo en mi laptop se siente lento, mientras que en el primer ejemplo con Context normal, de haber alguno tipo de degradación de performance es imperceptible.
También probé utilizando 1000 consumidores y para el caso de nuestro Context observable, tuve que recurrir al siempre bien ponderado killall -9 “Google Chrome”
, pues el navegador dejó de responder.
El cuello de botella en nuestro ejemplo no tiene que ver con la cantidad de mutaciones que se realizan en el DOM, sino con la cantidad excesiva de renders que son gatillados.
¿Pues cómo salimos de este embrollo? ¿Y ahora quién podrá ayudarnos?
No contaban con mi astucia 💛
Afortunadamente nuestro problema de performance es un problema de implementación, más que un problema conceptual.
React, por diseño, suele agrupar actualizaciones cuando son gatilladas dentro de un método que para React sea conocido, como por ejemplo un callback de ciclo de vida o un event handler. Todo el resto de actualizaciones gatilladas dentro de una promesa o una función asíncrona (como en el sujeto al que nos suscribimos en nuestro custom hook useObservableState
) se tratarán de forma separada.
Para resolver este problema tenemos dos posibles alternativas que te voy a explicar a continuación.
La primera solución implica cambiar el lugar donde ejecutamos la actualización de nuestro estado. Nosotros sabemos de antemano que para todos los componentes el valor de count
debe ser idéntico. Nuestra fuente de verdad es el observable timer$
, por lo que sólo debemos preocuparnos de consumir ese valor y decirle a React que se renderice cuando este cambie — pero en vez de hacerlo cien o mil o n
veces, podríamos hacerlo sólo una vez.
Ignorando el hecho de que todos son el mismo componente. ¿Qué tienen en común nuestros 100 componentes TimerConsumer
? Démosle un vistazo al pedazo de código donde renderizamos nuestros consumidores:
const nodes = Array(100)
.fill("")
.map((_, index) => `id_${index}`);const App = () => {
return nodes.map(id => <TimerConsumer key={id} />);
};
Pues los cien componentes tienen como padre a App
, por lo que podemos invocar nuestro custom hook useObservableState
dentro de App
y pasarle count
a cada uno de los TimerConsumer
a través de props:
Puedes ver un demo actualizado acá.
Si bien para efectos del demo hemos resuelto nuestro problema de commits excesivos, nuestra solución nos priva de casi todos los beneficios que ganamos al utilizar Context. En primer lugar, si tenemos consumidores muy nesteados en el árbol de componentes, tendremos que pasar count
por props; igualmente tendremos que mantener de forma cuidadosa el lugar donde nos suscribimos al sujeto y dependiendo de la estructura de nuestra aplicación, quizás tengamos que suscribirnos en más de un sitio; y finalmente count
se va a actualizar cada 1 segundo, lo que puede generar renders innecesarios en otros componentes si no somos cuidadosos.
La segunda alternativa es decirle a React que nos haga caso, porque sabemos que el estado para cada suscripción es idéntico. Como mencioné anteriormente, React trata de forma distinta los cambios de estado provenientes de orígenes desconocidos, pero no debe ser necesariamente así si nosotros le indicamos lo contrario. Si tenemos un montón de actualizaciones que están generando commits individualmente, le podemos decir a React que agrupe estas actualizaciones en un solo commit utilizando una API llamada unstable_batchedUpdates
.
¡Que no te asuste el prefijo
unstable_
! Esta es una API usada internamente por React y varias librerías más (como Redux y MobX) para lidiar con este mismo problema.
Lo único que debemos hacer para arreglar nuestro ejemplo es identificar dónde se están invocando las actualizaciones de estado. En nuestro caso es dentro de la función interval
:
function interval(timer) {
const subscribers = new Set();
let count = 0; const handler = setInterval(() => {
count += 1; // BATCH UPDATES FOR THE WIN
ReactDOM.unstable_batchedUpdates(() => {
subscribers.forEach(subscriber => subscriber(count));
});
}, timer); return {
observe: observer => {
observer(count);
subscribers.add(observer); return {
dispose: () => {
subscribers.delete(observer);
}
};
},
dispose: () => {
clearInterval(handler);
}
};
}
Con sólo hacer esto ya dejamos de emitir commits como locos.
El último demo, al fin.
Conclusión
Uff, ha sido una larga travesía desde el comienzo de este artículo hasta este mismísimo párrafo y si has llegado hasta aquí, te quiero dar las gracias por tu atención.
Si bien inicialmente mi objetivo era explorar algunos patrones de programación reactiva en React, mientras fui probando e indagando más y más sobre cómo funcionaban las cosas y el por qué, me di cuenta que hay varios factores que hacen complejo mezclar ambos paradigmas. La forma en que React registra y agenda updates para la interfaz es pull-based mientras que abstracciones como un Subject en FRP (functional reactive programming) son push-based.
De todos modos y como ya hemos visto, es posible implementar soluciones de manejo de estado push-based en React (de hecho MobX es push-based, mientras que Redux es pull-based).
Pull-based vs push-based es un tópico de discusión tan viejo como las ciencias de la computación y si bien ambos paradigmas tienen sus ventajas y desventajas, implementar una estrategia para manejo de estados push-based nos permite lograr mejor performance, dada la relación explícita entre un productor de estado y un consumidor.
En sistemas push-based, un consumidor nunca va a recibir una actualización de un productor si es que no se ha suscrito a este. En un mundo pull-based, cada consumidor debe requerir periódicamente la data expuesta por el productor y discernir entonces si acaso las actualizaciones en el estado le incumben en absoluto.
Michel Weststrate (creador de MobX) nos da una muy buena explicación de las ventajas de push-based sobre pull-based en el siguiente hilo de twitter.
Igualmente si quieres saber más sobre por qué React decide implementar una estrategia pull-based, puedes leer el siguiente apartado en la documentación de React.
Ahora que ya tengo un mejor entendimiento de cómo funciona React y de los desafíos que implica implementar un mecanismo de comunicación push-based con buena performance, me siento preparado para usar este conocimiento y crear algo útil.
Probablemente podría escribir al respecto en mi próximo artículo así que ponte atento porque va a estar buenísimo. 👀
Eso ha sido todo por hoy. Si te ha gustado el contenido, no te olvides de dejar algunos claps (que me suben la moral) y de seguirme en Twitter que tengo Twitter (donde hablo principalmente de JavaScript) y de seguirme por acá en Medium igualmente para que te enteres cada vez que publique un artículo.
Gracias totales y hasta la próxima.