DEV Community

quojs
quojs

Posted on

Comment j'ai arrêté de redessiner l'univers : une histoire d'abonnements atomiques dans React

Comment j'ai construit l'animation du logo Quo.js avec un moteur externe à React, des souscriptions atomiques dans React, et presque aucun code répétitif.

TL;DR : J'ai transformé un PNG statique du logo Quo.js en centaines de cercles SVG animés qui s'assemblent, se dispersent autour de la souris et reviennent en douceur — le tout en React 19 + TypeScript utilisant @quojs/core et @quojs/react. L'astuce consiste à exécuter un petit moteur complètement en dehors de React, en diffusant des mises à jour d'état groupées dans un store Quo.js, puis en effectuant le rendu avec des souscriptions atomiques dans React afin que l'interface utilisateur ne soit re-rendue que lorsque les coordonnées d'un cercle spécifique changent réellement.

Technologies : React 19, TypeScript, Vite, SVG

État : @quojs/core et @quojs/react

Ce qui n'est pas utilisé : three.js, WebGL, WebGPU. Ceci est du pur DOM/SVG + requestAnimationFrame.


Ce que vous verrez (GIFs)

Si vous visitez le site web de Quo.js, vous verrez ceci :

  • Animation d'intro du logo Quo.js — les cercles s'assemblent en logo Quo.js Intro : les particules convergent pour former le logo "Quo.js".

Et si vous jouez avec le curseur :

  • Interactivité du logo Quo.js — les cercles évitent le curseur et reviennent Interactivité : les cercles s'éloignent du curseur en orbite, puis reviennent à leur position "d'origine".

Dev.to pourrait compresser les fichiers GIF, si vous voyez des animations saccadées, envisagez de télécharger les fichiers GIF (intro et interaction) ou visitez le site web de Quo.js pour du 60fps fluide.


L'idée en un diagramme

Nous gardons React léger en déplaçant tout le travail d'animation dans un simple nano-moteur TypeScript. Le moteur possède la boucle de frames, le quadtree et les calculs de mouvement. Il envoie des mises à jour groupées à un store Quo.js. React ne consomme que la petite portion dont il a besoin par cercle via des sélecteurs atomiques.

Mermaid

  • Moteur (hors de React) : boucle requestAnimationFrame, limitation dt, lissage FPS, quadtree, physique des cercles.
  • Store Quo.js : réducteurs purement fonctionnels, effets pour les petits timings et événements de cycle de vie.
  • React : rend simplement les nœuds circle via useSliceProp('logo', 'd.circle_0') etc.

Pourquoi cette conception ? (Performance et simplicité)

  1. React reste inactif sauf si nécessaire. Chaque composant <Circle> souscrit à son propre chemin x,y. Pas de re-rendus globaux. Pas de prop drilling.
  2. Le moteur est agnostique du framework. Il ne connaît pas React. Il dispatche simplement des actions (batchUpdate) vers le store.
  3. Moins de code répétitif que Redux/RTK. Quo.js utilise des channels + événements, des effets sans thunks/sagas, et des sélecteurs atomiques pour souscrire à des chemins pointés exacts.
  4. Mises à jour groupées par frame + un réducteur serré maintiennent les écritures d'état minimales.
  5. rAF + limitation dt + buffer circulaire FPS offrent un mouvement fluide sans accrocs.

Étape 1 – Modéliser l'état et les événements de canal

Nous gardons l'état logo pour l'animation.

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

Modèle de dispatch :

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

Étape 2 – Le réducteur : immuable, rapide

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

// sucre syntaxique pour l'inférence de type
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];

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

  // mise à jour uniquement si quelque chose a réellement changé
  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
  • Éviter les écritures si x,y inchangés.
  • Grouper plusieurs mises à jour par frame.
  • Toucher uniquement les clés modifiées.

Étape 3 – Créer le store

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

Étape 4 – Le moteur (hors de React)

  • Exécute la boucle rAF.
  • Calcule dt et FPS.
  • Dispatche logo/batchUpdate.
  • Souscrit aux effets du store pour start/stop.
  • N'importe jamais 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

Chaque cercle émet des mises à jour { group, id, x, y } lorsqu'il se déplace.


Étape 5 – Relier le moteur et le store dans 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

Attendez, manu—comment transformons-nous un PNG en un essaim de cercles ?

Sous le capot, extractCircleSpecsFromImage charge le PNG du logo dans un canvas hors écran, échantillonne l'alpha des pixels sur une grille configurable (par défaut : espacement de 8px), et émet une spécification de cercle partout où alpha > 50.

const threshold = 0.2; // 0–1, ajustez pour la densité
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

Vous voulez un essaim plus dense ? Réduisez l'espacement. Vous voulez un éclairage d'ambiance ? Mappez alpha → rayon. Ce ne sont que des données—hackez à volonté.


Étape 6 – Colle React avec @quojs/react

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

Maintenant chaque <Circle> souscrit atomiquement :

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

Seul ce cercle est re-rendu lorsque ses propres coordonnées changent.


Récapitulatif du flux de données

Mermaid


FAQ / Notes

  • Pourquoi pas RTK ? Besoin d'un modèle channel/event, effets async, souscriptions atomiques.
  • Pourquoi pas three.js/WebGL ? Excessif pour des particules 2D ; SVG + Quo.js est léger.
  • Complétion de l'intro ? La simulation suit les cercles restants → dispatche introProgressintroComplete.
  • FPS ? Moyenné via buffer circulaire, dispatché avec parcimonie.

Pourquoi des dispatchs parcimonieux, pas du spam par frame ?
Inonder React de 60 mises à jour d'état/sec est un big-bang de rendu. Au lieu de cela, le moteur utilise un buffer circulaire pour suivre les temps de frame, calculant le FPS uniquement lorsque le buffer boucle (~toutes les 30 frames).

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

Résultat ? Une mise à jour atomique toutes les ~500ms, zéro saccade, et Redux Devtools reste sain d'esprit. —oui, vous avez bien lu : Quo.js supporte Redux Devtools—. La télémétrie de performance doit servir l'animation—pas la priver.


Reproduire localement

pnpm add @quojs/core@0.2.0 @quojs/react@0.2.0
Enter fullscreen mode Exit fullscreen mode
  1. Créer le store (voir Étape 3).
  2. Envelopper <AppStoreContext.Provider value={store}>.
  3. Monter le moteur (Étape 5).
  4. Rendre les cercles SVG (Étape 6).

Documentation pour Quo.js et compagnie :


Petits mais importants patterns

  • Écriture groupée par frame.
  • Protection no-op sur valeurs identiques.
  • Souscriptions à chemin exact.
  • Protection des tokens de génération rAF.
  • Dispatch parcimonieux de télémétrie.

Essayez-le !


Réflexions finales

Cette animation montre comment React peut être un moteur de rendu, pas une boucle de jeu. Avec la gestion de changements d'état précis et granulaires, vous obtenez un FPS élevé, peu d'agitation React, et une logique maintenable.

Une note rapide sur les noms des hooks pour useSliceProp et useSliceProps : Ceux-ci vont probablement changer pour quelque chose de plus significatif comme useAtomicProp et useAtomicProps avant la prochaine version (dans une semaine), alors ne les utilisez pas encore en production. Quo.js est toujours en phase de tests bêta.

Bon codage, manu.

Licence : MPL-2.0 — partagez avec le monde.

Top comments (0)