Sobre Observables, Performance y Magia Negra en React con Context y Hooks

Osman Cea
Osman Cea
Oct 14 · 15 min read

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…

Dad jokes

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:

Primer commit

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:

Segundo commit
Tercer commit
Cuarto commit… ya te hiciste una idea.
  1. TimerProvider se renderiza tras cada commit porque el estado interno del componente, en este caso time, es actualizado en el callback que le hemos pasado a setInterval dentro de nuestro useEffect.
  2. Luego, Context.Provider se renderiza básicamente porque su padre se ha renderizado.
  3. App no se renderiza: en el Profiler los componentes que no se renderizan salen de color gris.
  4. Finalmente, es el turno de TimerConsumer de ser renderizado, debido a que el valor provisto por el contexto que estamos consumiendo con useContext 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:

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?

Oye! Oye! Más lento, cerebrito.

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:

Accediendo al valor vía closure… TAMALO.

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:

Cambiando el estado de toggled

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…

yada-yada-yada

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:

  1. Aumentar un contador desde cero cada cierto intervalo de tiempo.
  2. 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.
  3. 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 a setValue en un componente desmontado. Por lo mismo debemos ser capaces de eliminar un observador cuando sea pertinente (en la función de cleanup de nuestro useEffect).
  4. Finalmente debemos detener nuestro setInterval cuando TimerProvider 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 con useState y useEffect, pero perfectamente podríamos hacer que el contador fuera lazy, o que en otras palabras el setInterval 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.

Primer commit sin observables
Primer commit CON observables

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:

Segundo commit sin observables
Segundo commit usando observables

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:

Primer commit sin observables, 100 consumidores
Segundo commit sin observables, 100 consumidores
Tercer commit sin observables, 100 consumidores

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:

Primer commit de 402…
… Ok??
Commit 102: nuestro último consumidor se renderiza.
Commit 103: El contador cambia a 2 y el primer consumidor se renderiza.
Ok… qué fue eso?

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 TimerConsumers, 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 TimerConsumers, 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?

Mi cara en este momento

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.

Esto se ve muy saludable.

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.

NodersJS

¿Por qué? Porque nos gusta.

Osman Cea

Written by

Osman Cea

Front End Dev. at Cornershop.

NodersJS

NodersJS

¿Por qué? Porque nos gusta.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade