DEV Community

quojs
quojs

Posted on

How I stopped re-rendering the universe: A tale of atomic subscriptions in React

How I built the Quo.js logo animation with an Engine outside React, atomic subscriptions inside React, and almost zero boilerplate.

TL;DR: I turned a static PNG of Quo.js logo into hundreds of animated SVG circles that assemble, disperse around the mouse, and glide home — all in React 19 + TypeScript using @quojs/core@0.2.0 and @quojs/react@0.2.0. The trick is running a small Engine completely outside React, streaming batched state updates into a Quo.js store, and then rendering with atomic subscriptions in React so the UI only re-renders when a specific circle’s coordinates actually change.

Tech: React 19, TypeScript, Vite, SVG

State: @quojs/core@0.2.0 and [@quojs/react@0.2.0](https://github.com/quojs/quojs/blob/main/packages/react/README.md_**What’s not used**: three.js, WebGL, WebGPU. This is pure DOM/SVG + requestAnimationFrame.


What you’ll see (GIFs)

If you visit Quo.js' website, you will see this:

  • Quo.js logo intro animation — circles assemble into Quo.js logo Intro: particles converge into the “Quo.js” logo mark.

And if you play with the cursor:

  • Quo.js logo interactivity — circles avoid the cursor and relax back Interactivity: circles orbit away from the cursor, then relax back to “home.”

Dev.to might compress the GIF files, if you see sluggish animations, consider downloading the GIF files (intro and interaction) or visiting https://quojs.dev for buttery 60fps.


The idea in one diagram

We keep React lean by pushing all animation work into a plain, simple TypeScript nano-Engine. The Engine owns the frame loop, quadtree, and motion math. It sends batched updates to a Quo.js Store. React consumes only the tiny slice it needs per circle via atomic selectors.

Mermaid

  • Engine (outside React): requestAnimationFrame loop, dt clamp, FPS smoothing, quadtree, circle physics.
  • Quo.js Store: purely functional reducers, effects for small timings and lifecycle events.
  • React: just renders circle nodes via useSliceProp('logo', 'd.circle_0') etc.

Why this design? (Performance & simplicity)

  1. React stays idle unless needed. Each <Circle> component subscribes to its own x,y path. No global re-renders. No prop drilling.
  2. Engine is framework-agnostic. It doesn’t know about React. It just dispatches actions (batchUpdate) to the store.
  3. Less boilerplate than Redux/RTK. Quo.js uses channels + events, effects without thunks/sagas, and atomic selectors to subscribe at exact dotted paths.
  4. Batched updates per frame + a tight reducer keep state writes minimal.
  5. rAF + dt clamp + FPS ring buffer give smooth motion without stutters.

Step 1 – Model the state and channel events

We keep state logo For the 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

Dispatch pattern:

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

Step 2 – The reducer: immutable, fast

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

// suggar for type inference
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];

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

  // update only if something actually changed
  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
  • Avoid writes if x,y unchanged.
  • Batch many updates per frame.
  • Only touch changed keys.

Step 3 – Create the store

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

Step 4 – The Engine (outside React)

  • Runs rAF loop.
  • Computes dt and FPS.
  • Dispatches logo/batchUpdate.
  • Subscribes to store effects for start/stop.
  • Never imports 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

Each Circle emits { group, id, x, y } updates when it moves.


Step 5 – Bridge Engine and Store in 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

Hold-on, manu—how do we turn a PNG into a swarm of circles?

Under the hood, extractCircleSpecsFromImage loads the logo PNG into an offscreen canvas, samples pixel alpha at a configurable grid (default: 8px spacing), and emits a circle spec wherever alpha > 50.

const threshold = 0.2; // 0–1, tweak for density
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

Want a denser swarm? Crank down spacing. Want mood lighting? Map alpha → radius. It’s just data—hack away.


Step 6 – React glue with @quojs/react

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

Now each <Circle> subscribes atomically:

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

Only that circle re-renders when its own coords change.


Data flow recap

Mermaid


FAQ / Notes

  • Why not RTK? Needed channel/event model, async effects, atomic subs.
  • Why not three.js/WebGL? Overkill for 2D particles; SVG + Quo.js is lean.
  • Intro completion? Simulation tracks remaining circles → dispatches introProgressintroComplete.
  • FPS? Averaged via ring buffer, dispatched sparsely.

Why sparse dispatches, not per-frame spam?
Flooding React with 60 state updates/sec is a render big-bang. Instead, the Engine uses a ring buffer to track frame times, computing FPS only when the buffer wraps (~every 30 frames).

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

Result? One atomic update every ~500ms, zero jank, and Redux Devtools stay sane. —yes, you've read it: Quo.js supports Redux Devtools— . Performance telemetry should serve the animation—not starve it.


Reproduce locally

pnpm add @quojs/core@0.2.0 @quojs/react@0.2.0
Enter fullscreen mode Exit fullscreen mode
  1. Create store (see Step 3).
  2. Wrap <AppStoreContext.Provider value={store}>.
  3. Mount Engine (Step 5).
  4. Render SVG Circles (Step 6).

Docs for Quo.js and friends:


Small but impactful patterns

  • Batch writes per frame.
  • No-op guard on identical values.
  • Exact-path subscriptions.
  • Guard rAF generation tokens.
  • Sparse telemetry dispatch.

Try it out!


Closing thoughts

This animation shows how React can be a renderer, not a game loop. With handling precise, granular state changes, you get high FPS, low React churn, and maintainable logic.

A quick note on the hook names for useSliceProp and useSliceProps: These will probably change to something more meaningful like useAtomicProp and useAtomicProps before the next release (in a week), so do not use them yet in prod. Quo.js is still in beta testing.

Have a nice coding, manu.

License: MPL-2.0 — share with the world.

Top comments (0)