DEV Community

quojs
quojs

Posted on

Cómo dejé de re-renderizar el universo: Una historia de suscripciones atómicas en React

Cómo construí la animación del logo de Quo.js con un Engine fuera de React, suscripciones atómicas dentro de React, y casi cero código repetitivo.

TL;DR: Convertí un PNG estático del logo de Quo.js en cientos de círculos SVG animados que se ensamblan, se dispersan alrededor del mouse, y regresan suavemente a casa — todo en React 19 + TypeScript usando @quojs/core@0.2.0 y @quojs/react@0.2.0. El truco es ejecutar un pequeño Engine completamente fuera de React, transmitir actualizaciones de estado en lotes a un store de Quo.js, y luego renderizar con suscripciones atómicas en React para que la UI solo se re-renderice cuando las coordenadas de un círculo específico realmente cambien.

Tech: React 19, TypeScript, Vite, SVG

State: @quojs/core y @quojs/react

Lo que NO se usa: three.js, WebGL, WebGPU. Esto es puro DOM/SVG + requestAnimationFrame.

Lo que verás (GIFs)

Si visitas el sitio web de Quo.js, verás esto:

  • Animación de introducción del logo de Quo.js — los círculos se ensamblan en el logo de Quo.js Intro: las partículas convergen en la marca del logo "Quo.js".

Y si juegas con el cursor:

  • Interactividad del logotipo de Quo.js: los círculos evitan el cursor y vuelven a su posición original . Interactividad: los círculos orbitan alejándose del cursor y luego regresan a su punto de partida.

Dev.to podría comprimir los archivos GIF, si ves animaciones lentas, considera descargar los archivos GIF (intro e interacción) o visitar https://quojs.dev/?lang=es para un fluido 60fps.

La idea en un diagrama

Mantenemos React ligero al empujar todo el trabajo de animación a un nano-Engine simple y plano de TypeScript. El Engine posee el bucle de frames, quadtree y matemáticas de movimiento. Envía actualizaciones en lotes a un Store de Quo.js. React consume solo el pequeño fragmento que necesita por círculo mediante selectores atómicos.

Mermaid

  • Engine (fuera de React): bucle requestAnimationFrame, clamp de dt, suavizado de FPS, quadtree, física de círculos.
  • Store Quo.js: reducers puramente funcionales, efectos para temporizaciones pequeñas y eventos de ciclo de vida.
  • React: solo renderiza nodos de círculo vía useSliceProp('logo', 'd.circle_0') etc.

¿Por qué este diseño? (Rendimiento y simplicidad)

  • React permanece inactivo a menos que sea necesario. Cada componente <Circle> se suscribe a su propia ruta x,y. Sin re-renders globales. Sin prop drilling.
  • El Engine es agnóstico del framework. No sabe sobre React. Solo despacha acciones (batchUpdate) al store.
  • Menos código repetitivo que Redux/RTK. Quo.js usa canales + eventos, efectos sin thunks/sagas, y selectores atómicos para suscribirse a rutas exactas con notación de puntos.
  • Actualizaciones en lotes por frame + un reducer compacto mantienen las escrituras de estado mínimas.
  • rAF + clamp de dt + buffer circular de FPS dan movimiento suave sin tartamudeos.

Paso 1 – Modelar el estado y eventos de canal

Mantenemos el estado logo para la animación.

export type Circle = { id: string; x: number; y: number; r?: number };
export type GroupedCircle = { group: "d" | "u" | "x" } & Circle;

export type LogoState = {
  enabled: boolean;
  fps: number;
  itemCount: { d: number; u: number; x: number };
  size: { height: number; width: number };
  d: Record<string, Circle>;
  u: Record<string, Circle>;
  x: Record<string, Circle>;
  intro: { remaining: number; total?: number; done: boolean };
};

export type LogoAM = {
  start: {};
  stop: {};
  fps: { fps: number };
  size: { height: number; width: number };
  count: { d: number; u: number; x: number };
  update: GroupedCircle;
  batchUpdate: { changes: GroupedCircle[] };
  introProgress: { remaining: number; total: number };
  introComplete: {};
};
Enter fullscreen mode Exit fullscreen mode

Patrón de dispatch:

store.dispatch("logo", "batchUpdate", { changes: [...] });
Enter fullscreen mode Exit fullscreen mode

Paso 2 – El reducer: inmutable, rápido

import type { ActionPair, ReducerSpec } from "@quojs/core";
import type { AppAM, LogoAM, LogoState, Circle } from "../types";

export const LOGO_INITIAL_STATE: LogoState = {
  enabled: true,
  d: Object.create(null),
  u: Object.create(null),
  x: Object.create(null),
  fps: 0,
  itemCount: { d: 0, u: 0, x: 0 },
  size: { height: 0, width: 0 },
  intro: {
    remaining: 0,
    total: 0,
    done: false,
  },
};

const LOGO_ACTIONS = [
  ["logo", "update"],
  ["logo", "stop"],
  ["logo", "fps"],
  ["logo", "size"],
  ["logo", "count"],
  ["logo", "batchUpdate"],
  ["logo", "introProgress"],
  ["logo", "introComplete"],
  ["logo", "start"],
] as const satisfies readonly ActionPair<AppAM>[];

// azúcar para inferencia de tipos
type GroupKey = keyof Pick<LogoState, "d" | "u" | "x">;

function upsertItem(state: LogoState, group: GroupKey, next: Circle): LogoState {
  const groupMap = state[group];
  const prev = groupMap[next.id];

  // insertar
  if (!prev) {
    const nextGroup = { ...groupMap, [next.id]: next };
    return { ...state, [group]: nextGroup };
  }

  // actualizar solo si algo realmente cambió
  if (
    prev.x === next.x &&
    prev.y === next.y
  ) {
    return state; // no-op
  }

  const nextGroup = { ...groupMap, [next.id]: { ...prev, ...next } };
  return { ...state, [group]: nextGroup };
}

export const logoReducer: ReducerSpec<LogoState, AppAM> = {
  actions: [
    ...LOGO_ACTIONS
  ],
  state: LOGO_INITIAL_STATE,
  reducer: (state, action) => {
    if (action.channel !== "logo") return state;
    if (!state.enabled && action.event !== "start") return state;

    switch (action.event) {
      case "update": {
        const { group, id, x, y } = action.payload;
        const next: Circle = { id, x, y };

        return upsertItem(state, group as GroupKey, next);
      }

      case "start": {
        if (state.enabled) return state;

        return {
          ...state,
          enabled: true,
        };
      }

      case "stop": {
        if (!state.enabled) return state;

        return {
          ...state,
          enabled: false
        };
      }

      case "fps": {
        const { fps } = action.payload as LogoAM["fps"];

        if (state.fps === fps) return state;

        return {
          ...state,
          fps,
        };
      }

      case "count": {
        const next = action.payload as LogoAM["count"];
        const prev = state.itemCount;

        if (prev.d === next.d && prev.u === next.u && prev.x === next.x) return state;

        return { ...state, itemCount: next };
      }

      case "size": {
        const { height, width } = action.payload as LogoAM["size"];
        const prev = state.size;

        if (prev.height === height && prev.width === width) return state;

        return {
          ...state, size: {
            height,
            width,
          }
        };
      }

      case "batchUpdate": {
        if (!action.payload.changes.length) return state;
        const { changes } = action.payload;

        let wroteAny = false;
        for (const c of changes) {
          let prev = state[c.group][c.id];

          if (!prev) {
            prev = {
              ...state[c.group][c.id],
              ...c,
            };

            state = {
              ...state,
              [c.group]: {
                ...state[c.group],
                [c.id]: prev,
              }
            };

            wroteAny = true;
            continue;
          }

          const nx = c.x ?? prev.x;
          const ny = c.y ?? prev.y;

          if (nx !== prev.x || ny !== prev.y) {
            state = {
              ...state,
              [c.group]: {
                ...state[c.group],
                [c.id]: { ...prev, x: nx, y: ny },
              }
            };

            wroteAny = true;
          }
        }

        return wroteAny ? { ...state } : state;
      }

      case "introProgress": {
        const { remaining, total } = action.payload;

        return {
          ...state,
          intro: {
            ...state.intro,
            remaining,
            total,
          }
        };
      }

      case "introComplete": {
        return {
          ...state,
          intro: {
            ...(state.intro ?? {}),
            remaining: 0,
            done: true
          }
        };
      }

      default:
        return state;
    }
  },
};
Enter fullscreen mode Exit fullscreen mode
  • Evita escrituras si x,y no han cambiado.
  • Actualiza muchos cambios en lote por frame.
  • Solo toca las claves modificadas.

Paso 3 – Crear el store

export const store = createStore({
  name: "Quo.js",
  reducer: { logo: logoReducer },
  effects: [],
});
Enter fullscreen mode Exit fullscreen mode

Paso 4 – El Engine (fuera de React)

  • Ejecuta bucle rAF.
  • Calcula dt y FPS.
  • Despacha logo/batchUpdate.
  • Se suscribe a efectos del store para start/stop.
  • Nunca importa React.
if (dt > 0) this.simulation.loop(this._dt, now);
if (this._running && gen === this._rafGen) {
  this._handle = requestAnimationFrame((t) => this._tick(t, gen));
}
Enter fullscreen mode Exit fullscreen mode

Cada Circle emite actualizaciones { group, id, x, y } cuando se mueve.

Paso 5 – Conectar Engine y Store en React

useEffect(() => {
  const engine = new Engine({ targetFPS: 60, autoStart: false }, store);
  const setup = async () => {
    const image = await loadImagePixels(quoLogo);
    const { specs, width, height, groupCounts } = extractCircleSpecsFromImage(
      image,
      { spacing: 3, initialR: 0.5, maxCircles: 1500 }
    );

    store.dispatch("logo", "size", { height, width });
    store.dispatch("logo", "count", groupCounts);

    const sim = new Simulation(engine, { items: specs, name: "Quo Packing" });

    engine.attach(sim);
    engine.init();
    engine.start();
  };

  setup();
  return () => engine.teardown();
}, [store]);
Enter fullscreen mode Exit fullscreen mode

Espera, manu—¿cómo convertimos un PNG en un enjambre de círculos?

Bajo el capó, extractCircleSpecsFromImage carga el PNG del logo en un canvas fuera de pantalla, muestrea el alpha de píxeles en una cuadrícula configurable (predeterminado: espaciado de 8px), y emite una especificación de círculo donde alpha > 50.

const threshold = 0.2; // 0–1, ajusta para densidad
const spacing = 8;

for (let y = 0; y < h; y += spacing) {
  for (let x = 0; x < w; x += spacing) {
    const alpha = ctx.getImageData(x, y, 1, 1).data[3] / 255;
    if (alpha > threshold) {
      circles.push({ x, y, r: spacing * 0.45 });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

¿Quieres un enjambre más denso? Reduce el espaciado. ¿Quieres iluminación ambiental? Mapea alpha → radio. Es solo data—hackéalo a tu gusto.

Paso 6 – Pegamento de React con @quojs/react

export const { useStore, useDispatch, useSliceProp } =
  createQuoHooks(AppStoreContext);
Enter fullscreen mode Exit fullscreen mode

Ahora cada <Circle> se suscribe atómicamente:

export const Circle = ({ id, group }) => {
  const path = `${group}.${id}`;

  const { x, y } = useSliceProp({
    reducer: "logo",
    property: path,
  }) ?? { x: 0, y: 0 };

  return <circle className={`group-${group}`} cx={`${x}px`} cy={`${y}px`} />;
};
Enter fullscreen mode Exit fullscreen mode

Solo ese círculo se re-renderiza cuando sus propias coordenadas cambian.

Resumen del flujo de datos

Mermaid

FAQ / Notas

  • ¿Por qué no RTK? Necesitaba modelo de canal/evento, efectos asíncronos, suscripciones atómicas.
  • ¿Por qué no three.js/WebGL? Exagerado para partículas 2D; SVG + Quo.js es ligero.
  • ¿Completado de intro? La simulación rastrea círculos restantes → despacha introProgress → introComplete.
  • ¿FPS? Promediado vía buffer circular, despachado escasamente.

¿Por qué despachos escasos, no spam por frame?

Inundar React con 60 actualizaciones de estado/seg es un big-bang de renderizado. En su lugar, el Engine usa un buffer circular para rastrear tiempos de frame, calculando FPS solo cuando el buffer se completa (~cada 30 frames).

if (this.frameCount % 30 === 0) {
  const fps = 30 / (deltaSum / 1000);
  this.store.dispatch(setFps(fps));
}
Enter fullscreen mode Exit fullscreen mode

¿Resultado? Una actualización atómica cada ~500ms, cero jank, y Redux Devtools permanece sano. —sí, lo leíste bien: Quo.js soporta Redux Devtools—. La telemetría de rendimiento debe servir a la animación—no matarla de hambre.

Reproducir localmente

pnpm add @quojs/core@0.2.0 @quojs/react@0.2.0
Enter fullscreen mode Exit fullscreen mode
  1. Crear store (ver Paso 3).
  2. Envolver <AppStoreContext.Provider value={store}>.
  3. Montar Engine (Paso 5).
  4. Renderizar SVG Circles (Paso 6).

Docs para Quo.js y amigos:

Patrones pequeños pero impactantes

  • Escrituras en lotes por frame.
  • Guardia de no-op en valores idénticos.
  • Suscripciones a rutas exactas.
  • Guardia de tokens de generación rAF.
  • Despacho escaso de telemetría.

¡Pruébalo!

Reflexiones finales

Esta animación muestra cómo React puede ser un renderizador, no un bucle de juego. Manejando cambios de estado precisos y granulares, obtienes alto FPS, poca agitación de React, y lógica mantenible.

Una nota rápida sobre los nombres de los hooks para useSliceProp y useSliceProps: Estos probablemente cambiarán a algo más significativo como useAtomicProp y useAtomicProps antes del próximo lanzamiento (en una semana), así que no los uses todavía en producción. Quo.js todavía está en pruebas beta.

Te deseo buen código, manu.

Licencia: MPL-2.0 — comparte con el mundo.

Top comments (0)