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 dispers…


This content originally appeared on DEV Community and was authored by quojs

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: {};
};

Padrão de dispatch:

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

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;
    }
  },
};
  • 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: [],
});

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));
}

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]);

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 });
    }
  }
}

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);

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`} />;
};

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));
}

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


This content originally appeared on DEV Community and was authored by quojs


Print Share Comment Cite Upload Translate Updates
APA

quojs | Sciencx (2025-11-05T21:30:46+00:00) Como parei de re-renderizar o universo: Uma história de assinaturas atômicas no React. Retrieved from https://www.scien.cx/2025/11/05/como-parei-de-re-renderizar-o-universo-uma-historia-de-assinaturas-atomicas-no-react/

MLA
" » Como parei de re-renderizar o universo: Uma história de assinaturas atômicas no React." quojs | Sciencx - Wednesday November 5, 2025, https://www.scien.cx/2025/11/05/como-parei-de-re-renderizar-o-universo-uma-historia-de-assinaturas-atomicas-no-react/
HARVARD
quojs | Sciencx Wednesday November 5, 2025 » Como parei de re-renderizar o universo: Uma história de assinaturas atômicas no React., viewed ,<https://www.scien.cx/2025/11/05/como-parei-de-re-renderizar-o-universo-uma-historia-de-assinaturas-atomicas-no-react/>
VANCOUVER
quojs | Sciencx - » Como parei de re-renderizar o universo: Uma história de assinaturas atômicas no React. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/11/05/como-parei-de-re-renderizar-o-universo-uma-historia-de-assinaturas-atomicas-no-react/
CHICAGO
" » Como parei de re-renderizar o universo: Uma história de assinaturas atômicas no React." quojs | Sciencx - Accessed . https://www.scien.cx/2025/11/05/como-parei-de-re-renderizar-o-universo-uma-historia-de-assinaturas-atomicas-no-react/
IEEE
" » Como parei de re-renderizar o universo: Uma história de assinaturas atômicas no React." quojs | Sciencx [Online]. Available: https://www.scien.cx/2025/11/05/como-parei-de-re-renderizar-o-universo-uma-historia-de-assinaturas-atomicas-no-react/. [Accessed: ]
rf:citation
» Como parei de re-renderizar o universo: Uma história de assinaturas atômicas no React | quojs | Sciencx | https://www.scien.cx/2025/11/05/como-parei-de-re-renderizar-o-universo-uma-historia-de-assinaturas-atomicas-no-react/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.