DEV Community

quojs
quojs

Posted on

⚡️ Redux Toolkit vs Quo.js: Why Granular State Beats the Global Reducer Model

Quo.js was born because I was done fighting two things in Redux/RTK apps:

  1. Selector gymnastics to avoid render storms, and
  2. Ceremony around async and side‑effects.

Below is an honest, technical comparison and an example of using Quo.js.


TL;DR

  • RTK: great DX vs raw Redux, but still broad, selector‑driven subscriptions; async via thunks/middleware; reducers keyed by slice; performance relies on memoization and careful equality.
  • Quo.js: typed channels + events and payloads, reducers subscribe to (channel, event) pairs, atomic dotted‑path subscriptions via connect(...), async middleware and effects built‑in, serialized dispatch queue, Redux DevTools support, and dynamic reducers/HMR‑friendly APIs.

If your pain is “too many re‑renders” or brittle selector trees, Quo’s path‑level reactivity and simple effect model are the pressure valves.


Mental model: where we diverge

Redux Toolkit (selector‑centric)

  • You organize state under slices.
  • Components read via useSelector(...) and you try to memoize everything.
  • Async work usually lives in thunks or extra middleware.
  • Performance depends on how narrow your selectors are and how cheap your equality is.

Quo.js (channel/event + atomic paths)

  • You declare reducers that list which (channel, event) pairs they react to, i.e. which events from which channels they care about.
  • You dispatch with dispatch(channel, event, payload).
  • UI (or services) subscribe atomically by reducer + dotted path, e.g. "items.3.title".
  • Async logic can live in middleware (pre‑reducer) or effects (post‑reducer), depending on your orchestration needs.

Example (Quo.js API)

Since Quo.js API is TypeScript-first, everything starts from a TypeScript design.

Import types and createStore from Quo.js core

import type {
  ActionPair,
  Readonly,
  EffectFunction,
  ReducerSpec,
  StoreInstance,
} from "@quojs/core";
import { createStore } from "@quojs/core";
Enter fullscreen mode Exit fullscreen mode

Define the shape of your Reducer state

This defines the state structure each reducer manages.

type CountState = { value: number };
Enter fullscreen mode Exit fullscreen mode

Define the shape of your App state

Group all reducers into a single application type — the shape of your global state.

type AppState = { count: CountState };
Enter fullscreen mode Exit fullscreen mode

ActionMap (Reducer)

ActionMaps describe two things:

  • the events for a specific Reducer
  • the shape of the payload for each event
type CounterAM = { add: number; subtract: number; set: number };
Enter fullscreen mode Exit fullscreen mode

In the above example, add, subtract and set become events.

ActionMap (Application)

Once you've defined an ActionMap for each Reducer, you can combine them into your global ActionMap.

type AppAM = { count: CounterAM };
Enter fullscreen mode Exit fullscreen mode

In this example, count becomes a channel, producing these channel + event pairs:

  • count + add
  • count + subtract
  • count + set

Reducers

A reducer describes how one slice of state changes in response to actions. Exactly as in Redux (RTK), with a subtle difference:

Reducers subscribe to specific [channel, event] pairs, where channels map to Reducer names and events to actions. This defines the (channel, event, payload) declarative contract.

const COUNT_ACTIONS = [
  ["count", "add"],
  ["count", "subtract"],
  ["count", "set"],
] as const satisfies readonly ActionPair<AppAM>[];

export const countReducer: ReducerSpec<CountState, AppAM> = {
  actions: [...COUNT_ACTIONS],
  state: { value: 0 },
  reducer: (state, action) => {
    switch (action.event) {
      case "add": return { value: state.value + action.payload };
      case "subtract": return { value: state.value - action.payload };
      case "set": return { value: action.payload };
      default: return state;
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

Middleware

Middleware in Quo.js are async by default. No thunks, sagas, or wrappers required. Middleware process actions before reducers, ideal for async logic or conditional vetoes.

export const LogMiddleware = async (getState, action, dispatch): Promise<boolean> => {
  console.dir(action); // log the action

  // Audit only when addition is attempted
  if (action.channel === "count" && action.event === "add") {
    const { count: { value: currentValue } } = getState();
    if (currentValue > 10) return false; // veto action
  }

  return true; // continue
};
Enter fullscreen mode Exit fullscreen mode

Effects

Effects run after reducers. getState() always reflects the latest version of state. Effects can dispatch actions — just avoid loops!

export const countResetEffect: EffectFunction<Readonly<AppState>, AppAM> = (
  action,
  getState,
  dispatch,
) => {
  if (action.channel !== "count" || action.event !== "add") return;
  const state = getState();
  if (state.count.value > 9) dispatch("count", "set", 0);
};
Enter fullscreen mode Exit fullscreen mode

Effects are declared as part of the createStore config, or dynamically after creation:

Effect that gets ALL events from all channels

store.registerEffect(countResetEffect); // 
Enter fullscreen mode Exit fullscreen mode

Effect for specific channel + event combination

store.onEffect("count", "set", async (action, getState, dispatch) => {
  console.log("Count state has been reset");
});
Enter fullscreen mode Exit fullscreen mode

Creating the Store

Ensure the Store is typed:

export type AppStore = StoreInstance<keyof AppState & string, AppState, AppAM>;
Enter fullscreen mode Exit fullscreen mode

The just call createStore() passing everyting we've created so far:

export const store = createStore({
  name: "Quo.js Rocks!", // Visible in Redux DevTools
  reducer: { count: countReducer },
  middleware: [LogMiddleware],
  effects: [countResetEffect],
});
Enter fullscreen mode Exit fullscreen mode

It may seem verbose, but stay with it — it pays off.


Subscriptions

Quo.js is framework‑agnostic: as per today, it runs on React, Node.js, or Deno. React bindings simply wrap these subscriptions via useSyncExternalStore, but that is topic for another post. Here, we are just exploring the core of Quo.js.

please share and comment if you want to see such a post focused on React.

But... if you are interested and can't wait, please check this example!

Read current state

console.log("INIT state:", store.getState()); // { count: { value: 0 } }
Enter fullscreen mode Exit fullscreen mode

Subscribe to state changes (coarse)

const unsubscribe = store.subscribe(() => {
  const { count } = store.getState();

  console.log("[STATE] count changed:", count);
});
Enter fullscreen mode Exit fullscreen mode

Subscribe to specific actions

const offAdd = store.on("count", "add", (payload: number) => {
  console.log("[ACTION] count:add", payload);
});

const offSet = store.on("count", "set", (payload: number) => {
  console.log("[ACTION] count:set", payload);
});
Enter fullscreen mode Exit fullscreen mode

Dispatch a few actions

store.dispatch("count", "set", 7);
store.dispatch("count", "add", 5);
console.log("After add(5):", store.getState().count);
Enter fullscreen mode Exit fullscreen mode

countResetEffect will auto‑reset count to 0 when it exceeds 9.

setTimeout(() => {
  offAdd();
  offSet();
  unsubscribe();
  console.log("Listeners removed.");
}, 200);
Enter fullscreen mode Exit fullscreen mode

Subscriptions and rendering behavior

RTK

  • useSelector runs on every store change; you rely on memoization/equality to bail out.
  • Wider selectors = more frequent updates.

Quo.js

  • Coarse: store.subscribe(() => ...) fires once per committed action.
  • Fine‑grained: store.connect({ reducer, property: "a.b.c" }, handler) fires only when that dotted path (or its ancestors) change.
  • Quo detects leaf changes via structural comparison and emits each leaf and its ancestors exactly once.

This eliminates accidental broad subscriptions and the “selector fan‑out” problem.


Dynamic reducers & HMR

Quo supports both replace and register patterns at runtime.

const dispose = store.registerReducer("filters", {
  state: { q: "" },
  actions: [["todos", "setAll"]] as const,
  reducer(s, a) {
    if (a.channel === "todos" && a.event === "setAll") return { q: "" };
    return s;
  },
});

dispose();

store.replaceReducers({ todos: todosReducer }, { preserveState: true });
Enter fullscreen mode Exit fullscreen mode

DevTools, immutability, and safety

  • State is frozen per‑slice on commit (freezeState(structuredClone(...))).
  • Redux DevTools integration is built‑in (time‑travel, import/export, jump, commit).
  • Dispatch is serialized via an internal FIFO queue with a re‑entrancy guard, ensuring effects dispatch safely without tearing.

Trade‑offs (honest list)

RTK — Pros

  • Huge ecosystem, tutorials, examples.
  • Familiarity for many teams.
  • DevTools, Immer, solid ergonomics.

RTK — Cons

  • Selector complexity and accidental wide subscriptions.
  • Async fragmentation (thunks, sagas, RTK‑Query, etc.).
  • Performance relies on memoization discipline.

Quo.js — Pros

  • Precise subscriptions via dotted paths (connect).
  • Async built‑in (middleware + effects).
  • Channel/event semantics keep reducers modular and explicit.
  • Dynamic reducers and HMR‑ready APIs.

Quo.js — Cons

  • New mental model (channels/events vs action types).
  • Smaller ecosystem (for now).
  • You still need to normalize large collections for efficient diffs.

Pragmatic migration tips

You don’t have to nuke RTK. Start small:

  1. Add a Quo store alongside Redux for one problematic slice.
  2. Rebuild that slice using Quo reducers and atomic subscriptions.
  3. Move async logic into effects. Delete thunks one by one.
  4. Replace fragile selectors with connect({ reducer, property }).
  5. Expand gradually — and use DevTools to compare behaviors.

Closing

RTK made Redux pleasant. Quo.js makes global state boring — in the best way.
You dispatch clear channel/event pairs, reducers stay small and local, async slots are obvious, and your UI only listens to what it actually cares about.

If you’ve fought React re‑renders and selector sprawl, this model will feel like taking ankle weights off your app.

— Manu (creator of Quo.js)

Top comments (1)

Collapse
 
quojs_dev profile image
quojs • Edited

*Quick poll: *

Redux Toolkit or Quo.js for your next project? ❤️ for RTK, 🦄 for Quo.js, 🔥 for both / neither!