wandres.dev
MÁQUINAS EN LA UI · React, Vue, Solid

Rendimiento: useSelector y suscripciones de grano fino

useMachine re-renderiza el componente en cada transición del actor, aunque la parte del snapshot que la vista usa no haya cambiado. Esta lección explica ese coste y su remedio: useSelector, que suscribe el componente a una rebanada del snapshot y solo lo re-renderiza cuando esa rebanada cambia según una función de comparación. Cubre la elección del comparador para valores derivados, el uso de useActorRef para obtener el actor sin suscribirse, y createActorContext para compartir un actor entre muchos componentes que seleccionan cada uno su porción sin sufrir la trampa de re-render del Context de React.

⏱ 18 min

useMachine es cómodo pero indiscriminado: suscribe el componente al snapshot entero, así que cada transición del actor lo re-renderiza, aunque el dato que ese componente muestra no haya cambiado. En una máquina pequeña da igual. En una que emite eventos a alta frecuencia —el progreso de una descarga, la posición de un cursor, un stream de mensajes—, o en un árbol donde muchos componentes observan el mismo actor, esa suscripción total se convierte en renders desperdiciados que se propagan hacia abajo. El remedio no es memoizar a ciegas: es suscribirse mejor. useSelector proyecta el snapshot a la porción exacta que un componente necesita y solo lo despierta cuando esa porción cambia.

🎯 Al terminar esta lección sabrás
  • Entender por qué useMachine re-renderiza en cada transición aunque el dato usado no cambie.
  • Suscribir un componente a una rebanada del snapshot con useSelector sobre un actorRef.
  • Elegir una función de comparación para controlar cuándo se considera que la rebanada cambió.
  • Compartir un actor con useActorRef y createActorContext sin re-renderizar todo el árbol.

El coste de suscribirse a todo

Cada transición produce un snapshot nuevo, y useMachine fuerza un render por cada snapshot. Si un componente solo pinta state.context.contador pero la máquina también emite eventos de posicionRaton, ese componente se re-renderiza con cada movimiento del ratón para no cambiar nada visible. El problema no es que el render sea caro por sí mismo, sino que se repite sin motivo y arrastra a los hijos. La pregunta correcta no es “¿cómo memoizo este componente?” sino “¿a qué debería estar suscrito de verdad?”.

// Sin selector: se re-renderiza en CADA transicion, aunque solo pinte el contador
const [state] = useMachine(maquina);
return <span>{state.context.contador}</span>;

// Con selector: solo se re-renderiza si cambia el contador
const contador = useSelector(actorRef, (s) => s.context.contador);
return <span>{contador}</span>;
flowchart TD
A[actor emite snapshot] --> B{useMachine}
A --> C{useSelector contador}
B --> D[render siempre]
C --> E{cambio la rebanada}
E -->|si| F[render]
E -->|no| G[nada]

Conviene ver el problema en su forma más aguda: si el componente que se re-renderiza de más tiene hijos, esos hijos también se re-evalúan salvo que estén memoizados, y la onda se propaga por el subárbol. Envolver cada hijo en memo ataca la propagación pero no la fuente —el padre sigue despertando sin necesidad—. Cortar el problema de raíz es no despertar al padre en primer lugar, y eso es exactamente lo que hace una suscripción de grano fino.

useSelector: una rebanada del snapshot

useSelector(actorRef, selector, comparar?) recibe el actor vivo —el tercer elemento de la tupla de useMachine, o uno obtenido con useActorRef— y una función que extrae la porción que te interesa. El hook suscribe el componente solo a esa porción: tras cada snapshot ejecuta el selector y compara el resultado nuevo con el anterior; si son iguales, no re-renderiza. El comparador por defecto es Object.is, que basta para primitivos.

import { useSelector } from "@xstate/react";

// contadorActor viene por props o de un contexto; NO usamos useMachine aqui
export function Contador({ actorRef }: { actorRef: ActorRefFrom<typeof maquina> }) {
  const contador = useSelector(actorRef, (snapshot) => snapshot.context.contador);
  // Este componente solo re-renderiza cuando cambia contador,
  // no cuando cambian otras partes del context o el estado finito.
  return <span>{contador}</span>;
}

La clave es que el selector se ejecuta en cada snapshot, pero el render solo ocurre si su salida cambió. Así trasladas el trabajo del render, que es caro, a una comparación, que es barata. Un selector bien elegido es la diferencia entre un componente que despierta mil veces por segundo y uno que despierta solo cuando su dato se mueve de verdad. Un matiz que se olvida a menudo: useSelector no evita que el selector corra en cada snapshot, solo que el componente se repinte; por eso el selector debe ser barato y sin efectos, un simple proyector que devuelve un valor comparable.

💡
El selector debe devolver algo estable de comparar

Si el selector devuelve un primitivo —un número, un string, un booleano—, Object.is lo compara bien y todo funciona. El peligro aparece cuando devuelves un objeto o un array derivado, como snapshot.context.items.filter(...): creas una referencia nueva en cada snapshot y Object.is la ve siempre distinta, anulando el ahorro. La solución es un comparador que mire el contenido, o seleccionar el dato crudo y derivar fuera.

Elegir el comparador

El tercer argumento de useSelector es una función (a, b) => boolean que decide si dos resultados del selector cuentan como iguales. Para una lista donde solo importa la identidad de sus elementos, una comparación superficial evita renders cuando el array se reconstruye con los mismos miembros. Es la misma disciplina que un selector de Redux memoizado: separar “el dato cambió de referencia” de “el dato cambió de valor”.

import { useSelector } from "@xstate/react";

const comparaShallow = (a: string[], b: string[]) =>
  a.length === b.length && a.every((x, i) => x === b[i]);

const nombres = useSelector(
  actorRef,
  (snapshot) => snapshot.context.usuarios.map((u) => u.nombre),
  comparaShallow,
);

El comparador corre en cada snapshot, así que también debe ser barato: una comparación superficial de longitud y elementos es aceptable, pero una comparación profunda recursiva sobre estructuras grandes puede costar más que el render que ahorra. Mide antes de asumir que comparar siempre gana; el objetivo es que el trabajo total baje, no sustituir un coste por otro mayor.

📝
Selecciona estrecho, deriva fuera

Hay dos formas de evitar que un selector derivado dispare renders falsos. La primera es el comparador de contenido que acabas de ver. La segunda, a menudo mejor, es seleccionar el dato crudo mínimo —snapshot.context.usuarios— y hacer la derivación —el map, el filter— con un useMemo fuera del selector, dependiente de ese dato. Así el selector devuelve una referencia estable mientras el crudo no cambie, y la derivación cara solo se recalcula cuando toca. Elegir entre comparador o derivación externa es una decisión de diseño, no un detalle.

useActorRef y createActorContext: un actor, muchos selectores

Para que useSelector valga la pena, el componente no debe suscribirse también con useMachine —eso reintroduciría el render total—. useActorRef(maquina) arranca el actor y devuelve solo el actorRef, sin suscripción ni render propio; luego cada componente elige su rebanada con useSelector. Para compartir ese actor por un árbol sin prop drilling, createActorContext(maquina) te da un Provider y hooks useSelector y useActorRef ligados a ese contexto.

import { createActorContext } from "@xstate/react";
import { maquina } from "./maquina";

export const AppMaquina = createActorContext(maquina);

// En la raiz: arranca un unico actor para todo el subarbol
// <AppMaquina.Provider>...</AppMaquina.Provider>

export function Badge() {
  // Cada consumidor selecciona su porcion y re-renderiza de forma independiente
  const pendientes = AppMaquina.useSelector((s) => s.context.pendientes);
  return <b>{pendientes}</b>;
}

El patrón que emerge es potente: un solo actor arranca en la raíz, vive una sola vez y es la única fuente de verdad; cada componente del árbol, por lejano que esté, se suscribe a la rebanada que le concierne y se re-renderiza solo por ella. Es lo contrario de levantar el estado a un padre común que, al cambiar, re-renderiza a todos sus descendientes. Aquí no hay padre que re-renderizar: hay un actor observado por muchos ojos independientes.

ℹ️
Esto evita la trampa clásica del Context de React

Un React Context normal re-renderiza a todos sus consumidores cuando su valor cambia, lo que lo hace mal vehículo para estado que cambia a menudo. createActorContext no sufre ese problema: por el Provider viaja el actorRef —una referencia estable que nunca cambia—, no el snapshot. Los consumidores no leen el valor del contexto; se suscriben al actor con useSelector. Así obtienes el alcance de un Context sin su penalización de re-render, que es justo lo que hace usable este patrón para estado de la app entera.

Todo esto tiene un límite que conviene decir en voz alta: el grano fino no sale gratis en complejidad, y no todo componente lo necesita ni se beneficia de él.

⚠️
No optimices sin medir

El grano fino tiene un coste: más hooks, selectores que mantener y comparadores que razonar. En máquinas pequeñas o con eventos poco frecuentes, useMachine es más simple y su render de más es imperceptible. Introduce useSelector cuando un perfil muestre renders que duelen, no por defecto. La regla es la de siempre: primero correcto y legible, y después rápido donde una medición —no una corazonada— lo justifique.

📡

useMachine: suscripción total

Simple y correcto por defecto. Re-renderiza en cada transición. Ideal para máquinas pequeñas o componentes que ya dependen de casi todo el snapshot.

🔬

useSelector: grano fino

Suscribe a una rebanada. Re-renderiza solo cuando esa rebanada cambia según el comparador. El bisturí para eventos frecuentes y árboles grandes.

🌳

createActorContext: un actor compartido

Un solo actor para el subárbol; cada componente selecciona lo suyo. Evita levantar el snapshot a un padre que re-renderizaría a todos.

Optimizar la UI reactiva es minimizar la superficie de suscripción, no memoizar renders

El reflejo importado del mundo de React es tratar los renders de más como un problema de memoización: envolver en memo, congelar props, cachear callbacks. Con un actor observable, ese reflejo ataca el síntoma y no la causa. La causa es la superficie de suscripción: a cuánto del estado está atado un componente para despertar. useMachine ata al snapshot entero, así que despierta ante cualquier cambio; useSelector ata a una proyección, así que despierta solo si esa proyección se mueve. El trabajo de rendimiento, entonces, no es memoizar componentes gordos sino estrechar suscripciones: que cada componente lea la mínima rebanada que necesita y la compare por valor, no por referencia. Esta es exactamente la lección de los selectores del nivel de reactividad, ahora aplicada al borde de React: un selector es una derivación memoizada, y suscribirse a la derivación en vez de a la fuente propaga solo los cambios que importan. El comparador es el corazón del asunto, porque decide qué cuenta como cambio; equivocarlo —devolver objetos nuevos que siempre difieren— disfraza de optimización lo que sigue siendo una suscripción total. Cuando piensas en términos de superficie de suscripción, useActorRef y createActorContext dejan de ser trucos y se revelan como lo que son: la forma de tener un único actor, una única fuente de verdad, observado por muchos ojos que solo ven, cada uno, la parte que les toca.

⚔️ Estrecha la suscripción
  1. Instrumenta un componente con useMachine y un contador de renders; dispara un evento de alta frecuencia y mide cuántas veces re-renderiza en vano.
  2. Sustituye useMachine por useActorRef más useSelector sobre el dato que realmente pintas y vuelve a medir.
  3. Cambia el selector para que devuelva un array derivado y observa cómo se pierde el ahorro; arréglalo con un comparador de contenido.
  4. Reescribe ese mismo caso seleccionando el dato crudo y derivando con useMemo fuera del selector; compara ambas soluciones.
  5. Monta createActorContext en la raíz y haz que tres componentes hermanos seleccionen cada uno una porción distinta; confirma que se re-renderizan por separado.
  6. Argumenta cuándo useMachine sigue siendo la elección correcta y cuándo el grano fino solo añade complejidad sin beneficio medible.