DEV Community

quojs
quojs

Posted on

Como parei de re-renderizar o universo: Uma história de assinaturas atômicas no React

Como construí a animação do logo do Quo.js com um Engine fora do React, assinaturas atômicas dentro do React, e quase zero boilerplate.

TL;DR: Transformei um PNG estático do logo do Quo.js em centenas de círculos SVG animados que se montam, se dispersam ao redor do mouse, e deslizam de volta para casa — tudo em React 19 + TypeScript usando @quojs/core e @quojs/react. O truque é rodar um pequeno Engine completamente fora do React, transmitir atualizações de estado em lotes para uma store do Quo.js, e então renderizar com assinaturas atômicas no React para que a UI só re-renderize quando as coordenadas de um círculo específico realmente mudem.

Tech: React 19, TypeScript, Vite, SVG

State: @quojs/core e @quojs/react

O que NÃO é usado: three.js, WebGL, WebGPU. Isso é puro DOM/SVG + requestAnimationFrame.

O que você verá (GIFs)

Se você visitar o site do Quo.js, verá isto:

  • Animação de introdução do logo do Quo.js — círculos se montam no logo do Quo.js

Intro: partículas convergem para a marca do logo "Quo.js".

E se você brincar com o cursor:

  • Interatividade do logo do Quo.js — círculos evitam o cursor e relaxam de volta

Interatividade: círculos orbitam para longe do cursor, então relaxam de volta para "casa".

Dev.to pode comprimir os arquivos GIF, se você vir animações lentas, considere baixar os arquivos GIF (intro e interação) ou visitar Quo.js site para 60fps suaves.

A ideia em um diagrama

Mantemos o React leve empurrando todo o trabalho de animação para um nano-Engine simples e plano de TypeScript. O Engine possui o loop de frames, quadtree e matemática de movimento. Ele envia atualizações em lotes para uma Store do Quo.js. O React consome apenas o pequeno pedaço que precisa por círculo via seletores atômicos.

Mermaid

  • Engine (fora do React): loop requestAnimationFrame, clamp de dt, suavização de FPS, quadtree, física de círculos.
  • Store Quo.js: reducers puramente funcionais, efeitos para pequenos temporizadores e eventos de ciclo de vida.
  • React: apenas renderiza nós de círculo via useSliceProp('logo', 'd.circle_0') etc.

Por que esse design? (Performance e simplicidade)

  • React fica ocioso a menos que seja necessário. Cada componente <Circle> se inscreve em seu próprio caminho x,y. Sem re-renders globais. Sem prop drilling.
  • Engine é agnóstico de framework. Ele não sabe sobre React. Apenas despacha ações (batchUpdate) para a store.
  • Menos boilerplate que Redux/RTK. Quo.js usa canais + eventos, efeitos sem thunks/sagas, e seletores atômicos para se inscrever em caminhos exatos com notação de pontos.
  • Atualizações em lotes por frame + um reducer compacto mantêm as escritas de estado mínimas.
  • rAF + clamp de dt + buffer circular de FPS dão movimento suave sem engasgos.

Passo 1 – Modelar o estado e eventos de canal

Mantemos o estado logo para a animação.

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

Padrão de dispatch:

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

Passo 2 – O reducer: imutável, 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>[];

// açúcar para inferência 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];

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

  // atualizar apenas se algo realmente mudou
  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 escritas se x,y não mudaram.
  • Atualiza muitas mudanças em lote por frame.
  • Apenas toca as chaves modificadas.

Passo 3 – Criar a store

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

Passo 4 – O Engine (fora do React)

  • Roda loop rAF.
  • Calcula dt e FPS.
  • Despacha logo/batchUpdate.
  • Se inscreve em efeitos da 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 atualizações { group, id, x, y } quando se move.

Passo 5 – Conectar Engine e Store no 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 aí, manu—como transformamos um PNG em um enxame de círculos?

Por baixo dos panos, extractCircleSpecsFromImage carrega o PNG do logo em um canvas fora da tela, amostra o alpha de pixels em uma grade configurável (padrão: espaçamento de 8px), e emite uma especificação de círculo onde alpha > 50.

const threshold = 0.2; // 0–1, ajuste para densidade
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

Quer um enxame mais denso? Diminua o espaçamento. Quer iluminação ambiente? Mapeie alpha → raio. É apenas dados—hackeie à vontade.

Passo 6 – Cola do React com @quojs/react

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

Agora cada <Circle> se inscreve atomicamente:

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

Apenas esse círculo re-renderiza quando suas próprias coordenadas mudam.

Resumo do fluxo de dados

Mermaid

FAQ / Notas

  • Por que não RTK? Precisava do modelo de canal/evento, efeitos assíncronos, assinaturas atômicas.
  • Por que não three.js/WebGL? Exagero para partículas 2D; SVG + Quo.js é leve.
  • Conclusão da intro? A simulação rastreia círculos restantes → despacha introProgress → introComplete.
  • FPS? Média via buffer circular, despachado esparsamente.

Por que despachos esparsos, não spam por frame?

Inundar o React com 60 atualizações de estado/seg é um big-bang de renderização. Em vez disso, o Engine usa um buffer circular para rastrear tempos de frame, calculando FPS apenas quando o buffer completa (~a 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? Uma atualização atômica a cada ~500ms, zero jank, e Redux Devtools permanecem sãos. —sim, você leu certo: Quo.js suporta Redux Devtools—. Telemetria de performance deve servir a animação—não matá-la de fome.

Reproduzir localmente

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

Docs para Quo.js e amigos:

Padrões pequenos mas impactantes

  • Escritas em lotes por frame.
  • Guarda de no-op em valores idênticos.
  • Assinaturas a caminhos exatos.
  • Guarda de tokens de geração rAF.
  • Despacho esparso de telemetria.

Experimente!

Dê uma estrela ao Quo.js no GitHub

🧪 Experimente @quojs/core@0.2.0

🧪 Experimente @quojs/react@0.2.0

🐞 Registre issues / ideias

🧩 Contribua exemplos

Reflexões finais

Esta animação mostra como React pode ser um renderizador, não um loop de jogo. Lidando com mudanças de estado precisas e granulares, você obtém alto FPS, pouca agitação do React, e lógica mantível.

Uma nota rápida sobre os nomes dos hooks para useSliceProp e useSliceProps: Estes provavelmente mudarão para algo mais significativo como useAtomicProp e useAtomicProps antes do próximo lançamento (em uma semana), então não os use ainda em produção. Quo.js ainda está em testes beta.

Tenha um lindo coding, manu.

Licença: MPL-2.0 — compartilhe com o mundo.

Top comments (0)