NodersJS
Published in

NodersJS

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

A crystal ball filled with magic
Photo by Aron Visuals on Unsplash
Fuente: https://github.com/facebook/react/issues/14110#issuecomment-448074060

Pero antes, un poco de contexto…

Dad jokes
Primer commit
  • 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.
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.

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 caso time, es actualizado en el callback que le hemos pasado a setInterval dentro de nuestro useEffect.

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.

Oye! Oye! Más lento, cerebrito.
Accediendo al valor vía closure… TAMALO.
Cambiando el estado de toggled

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
  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.
Primer commit sin observables
Primer commit CON observables
Segundo commit sin observables
Segundo commit usando observables

¡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.

const nodes = Array(100)
.fill("")
.map((_, index) => `id_${index}`);
const App = () => {
return nodes.map(id => <TimerConsumer key={id} />);
};
Primer commit sin observables, 100 consumidores
Segundo commit sin observables, 100 consumidores
Tercer commit sin observables, 100 consumidores
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?
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.

const nodes = Array(100)
.fill("")
.map((_, index) => `id_${index}`);
const App = () => {
return nodes.map(id => <TimerConsumer key={id} />);
};
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);
}
};
}
Esto se ve muy saludable.

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.

--

--

¿Por qué? Porque nos gusta.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store