npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

fluxdom

v0.12.0

Published

Hierarchical state management that scales with your ambition

Downloads

149

Readme

🌊 FluxDom

State management that scales with your ambition.

Tired of wrestling with global state? FluxDom brings order to chaos with a hierarchical, domain-driven approach. Build features in isolation, compose them together, and watch your state flow like water.

npm install fluxdom

No boilerplate. No providers. No nonsense.


⚡ Quick Start

Create a store in 30 seconds

import { domain } from "fluxdom";

// 1. Create a domain — your state's home
const app = domain("app");

// 2. Create a store — state can be anything (primitives, objects, arrays)
const counterStore = app.store({
  name: "counter",
  initial: 0,
  reducer: (state, action) => {
    switch (action.type) {
      case "INC":
        return state + 1;
      case "DEC":
        return state - 1;
      default:
        return state;
    }
  },
});

// 3. Dispatch — make things happen
counterStore.dispatch({ type: "INC" });
console.log(counterStore.getState()); // 1

Works with Vanilla JS

FluxDom is framework-agnostic. Use it anywhere — Node.js, browser, or any JavaScript runtime.

import { domain } from "fluxdom";

const app = domain("app");
const counterStore = app.store({
  name: "counter",
  initial: 0,
  reducer: (state, action) => {
    switch (action.type) {
      case "INC":
        return state + 1;
      default:
        return state;
    }
  },
});

// Subscribe to changes
counterStore.onChange(() => {
  document.getElementById("count").textContent = String(
    counterStore.getState()
  );
});

// Wire up events
document.getElementById("btn").addEventListener("click", () => {
  counterStore.dispatch({ type: "INC" });
});
<!-- Works in the browser too -->
<span id="count">0</span>
<button id="btn">+</button>

Drop it into React

💡 Tip: When using React, import everything from fluxdom/react — it re-exports all core APIs (domain, module, emitter, etc.) plus the React hooks. No need to import from both packages!

// ✅ Just import from fluxdom/react
import { domain, module, useSelector } from "fluxdom/react";

function Counter() {
  const count = useSelector(counterStore); // state is just a number!

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => counterStore.dispatch({ type: "INC" })}>+</button>
    </div>
  );
}

That's it. No <Provider> wrapping your app. No context drilling. Just import and use.


🏗️ Architecture

FluxDom organizes state into a tree of domains. Each domain is a self-contained universe that can hold stores, services, and child domains.

                    ┌─────────┐
                    │   app   │  ← Root Domain
                    └────┬────┘
              ┌──────────┼──────────┐
              ▼          ▼          ▼
         ┌────────┐ ┌────────┐ ┌────────┐
         │  auth  │ │  user  │ │ todos  │  ← Feature Domains
         └───┬────┘ └───┬────┘ └───┬────┘
             │          │          │
             ▼          ▼          ▼
         [stores]   [stores]   [stores]    ← Your State Lives Here

Why does this matter?

| Concept | What it does | Why you'll love it | | ------------- | ------------------------------ | -------------------------------------- | | Domain | Groups related state & logic | Features stay isolated & testable | | Store | Holds state with a reducer | Predictable updates, time-travel ready | | Actions ↓ | Flow from parent to children | Broadcast events across features | | Events ↑ | Bubble from children to parent | Monitor everything from one place | | Modules | Injectable services (DI) | Swap implementations for testing |


🧠 Core Concepts

Domains — Organize Your Universe

Domains are boundaries. They keep features separate, yet connected.

import { domain } from "fluxdom";

// Your app's root
const app = domain("app");

// Feature domains — nest as deep as you need
const auth = app.domain("auth");
const todos = app.domain("todos");
const todos_filters = todos.domain("filters"); // app.todos.filters

// Always know where you came from
auth.root === app; // true

Stores — Where State Lives

Every store has a name, initial state, and a reducer. You can use either actions() for type-safe action creators or the classic reducer function for more control.

Option 1: actions() (Recommended)

Use actions() to define action creators, then actions.reducer() to create a typed reducer:

import { domain, actions } from "fluxdom";

const app = domain("app");

// Define action creators with multiple formats
const counterActions = actions({
  increment: true, // no payload
  decrement: "COUNTER_DEC" as const, // custom type (use `as const`!)
  incrementBy: (n: number) => n, // with payload
  set: { type: "SET" as const, prepare: (v: number) => v }, // custom type + payload
});

// Action creators produce { type, payload } objects
counterActions.increment(); // { type: "increment", payload: undefined }
counterActions.decrement(); // { type: "COUNTER_DEC", payload: undefined }
counterActions.incrementBy(5); // { type: "incrementBy", payload: 5 }
counterActions.set(10); // { type: "SET", payload: 10 }

// Each action creator has a .type property
counterActions.increment.type; // "increment"
counterActions.set.type; // "SET"

// Create typed reducer with actions.reducer()
const counterReducer = actions.reducer(
  counterActions,
  (state: number, action) => {
    switch (action.type) {
      case "increment":
        return state + 1;
      case "COUNTER_DEC":
        return state - 1;
      case "incrementBy":
        return state + action.payload;
      case "SET":
        return action.payload;
      default:
        return state;
    }
  }
);

// Create store
const counterStore = app.store({
  name: "counter",
  initial: 0,
  reducer: counterReducer,
});

// Dispatch
counterStore.dispatch(counterActions.increment());
counterStore.dispatch(counterActions.incrementBy(5));

Combine multiple action sources (store + domain actions):

const app = domain("app");

const counterActions = actions({
  increment: true,
  set: (value: number) => value,
});

const domainActions = actions({
  resetAll: "RESET_ALL" as const,
});

// Combine any actions into one reducer!
const reducer = actions.reducer(
  [counterActions, domainActions], // array of action sources
  (state: number, action) => {
    switch (action.type) {
      case "increment":
        return state + 1;
      case "set":
        return action.payload;
      case "RESET_ALL":
        return 0;
      default:
        return state;
    }
  }
);

const store = app.store({ name: "counter", initial: 10, reducer });
store.dispatch(counterActions.increment()); // 11
app.dispatch({ type: "RESET_ALL" }); // 0

📝 Note: When using custom string types, add as const for proper type inference. Without it, the type is inferred as string instead of the literal type.

Benefits of actions():

  • 🎯 Type-safe — Action types and payloads fully inferred
  • 📦 Flexible definitionstrue, "TYPE", (args) => payload, or { type, prepare }
  • 🔗 Composable — Combine store actions + domain actions
  • 🔍 Matchable — Use .type and .match() for type narrowing
  • ♻️ Reusable — Same actions work with multiple stores

Option 2: Classic Reducer Function

For more control, use the traditional Redux-style reducer:

type TodoAction =
  | { type: "ADD"; text: string }
  | { type: "TOGGLE"; id: number };

interface TodoState {
  items: { id: number; text: string; done: boolean }[];
}

const todoStore = todos.store<TodoState, TodoAction>(
  "list",
  { items: [] },
  (state, action) => {
    switch (action.type) {
      case "ADD":
        return {
          items: [
            ...state.items,
            { id: Date.now(), text: action.text, done: false },
          ],
        };
      case "TOGGLE":
        return {
          items: state.items.map((t) =>
            t.id === action.id ? { ...t, done: !t.done } : t
          ),
        };
      default:
        return state;
    }
  }
);

Actions Architecture — Features vs Domains

Understanding the relationship between feature actions and domain actions is key to FluxDom's mental model.

┌─────────────────────────────────────────────────────────────┐
│ Feature Layer (Business Logic)                              │
│  • todoActions = actions("todos", { add, remove, toggle })  │
│  • userActions = actions("user", { login, logout })         │
│  • cartActions = actions("cart", { add, checkout })         │
└─────────────────────────────────────────────────────────────┘
                           ↓ dispatched to
┌─────────────────────────────────────────────────────────────┐
│ Domain Layer (State Organization)                           │
│  • app domain                                               │
│    ├── store: todos (handles todoActions)                   │
│    ├── store: user (handles userActions)                    │
│    └── subdomain: checkout                                  │
│        └── store: cart (handles cartActions + userActions)  │
└─────────────────────────────────────────────────────────────┘

Feature Actions — Business Logic

Actions created with actions() represent business operations, not domain structure. They're independent, reusable, and can be handled by any store.

// Feature actions — define WHAT operations exist
const todoActions = actions("todos", {
  add: (title: string) => ({ title }),
  remove: (id: number) => ({ id }),
  toggle: (id: number) => id,
});

const userActions = actions("user", {
  login: (credentials: Credentials) => credentials,
  logout: true,
});

// These actions can be dispatched to ANY store that handles them
todoStore.dispatch(todoActions.add("Buy milk"));

Domain Dispatch — No Restrictions

Domain dispatch accepts any action (AnyAction). Type safety comes from action creators and model actions, not from domain typing.

This is similar to Redux — the domain/store just routes actions, action creators provide type safety.

const app = domain("app");

// ✅ Dispatch any action with any properties
app.dispatch({ type: "RESET_ALL" });
app.dispatch({ type: "custom/action", payload: { data: 123 } });
app.dispatch(todoActions.add("Buy milk")); // Type-safe via action creator!

Cross-cutting actions (app-wide concerns):

const todoStore = app.model({
  name: "todos",
  initial: { items: [] },
  // Fallback builder handles domain actions with ctx.on()
  fallback: (ctx) => {
    ctx.on((state, action) => {
      if (action.type === "RESET_ALL") return { items: [] };
      if (action.type === "LOGOUT") return { items: [] };
      return state;
    });
  },
  actions: () => ({
    /* ... */
  }),
});

const settingsStore = app.model({
  name: "settings",
  initial: { theme: "light" },
  fallback: (ctx) => {
    ctx.on((state, action) => {
      if (action.type === "RESET_ALL") return { theme: "light" };
      return state;
    });
  },
  actions: () => ({
    /* ... */
  }),
});

// One dispatch, all stores handle it
app.dispatch({ type: "RESET_ALL" });

Summary

| Layer | Type Safety | How | | ------------------- | -------------- | -------------------------------- | | Domain dispatch | No restriction | Accepts AnyAction | | Action creators | ✅ Type-safe | actions("todos", { add: ... }) | | Model actions | ✅ Type-safe | model({ actions: ... }) | | Store reducers | ✅ Type-safe | actions.reducer(...) |

Key insight: Actions are feature-centric (business logic), not domain-centric (state structure). The prefix in actions("todos", {...}) represents the feature namespace, not where it's dispatched.


Derived Stores — Computed State, Zero Effort

Need to combine or transform data from multiple stores? Derived stores have your back. They're read-only, always fresh, and ridiculously efficient.

// Create derived store via domain method (recommended)
// Name becomes "todos.stats" — perfect for debugging
const stats = todos.derived("stats", todoStore, (todos) => ({
  total: todos.items.length,
  completed: todos.items.filter((t) => t.done).length,
}));

// Combine multiple stores from different domains
const dashboard = app.derived(
  "dashboard",
  [todoStore, userStore],
  (todos, user) => ({
    greeting: `Hey ${user.name}!`,
    pendingTasks: todos.items.filter((t) => !t.done).length,
  })
);

// Always up-to-date
stats.getState(); // { total: 3, completed: 1 }

// Subscribe to changes
stats.onChange(() => {
  console.log("Stats updated!", stats.getState());
});

Thunks — Async Made Simple

Need to fetch data? Handle side effects? Just dispatch a function.

// Store-level thunk — has access to local state
const fetchTodos = async ({ dispatch, getState }) => {
  if (getState().loading) return; // Already loading? Bail.

  dispatch({ type: "FETCH_START" });

  const response = await fetch("/api/todos");
  const data = await response.json();

  dispatch({ type: "FETCH_SUCCESS", payload: data });
};

todoStore.dispatch(fetchTodos);
// Domain-level thunk — orchestrate across features
const initializeApp = async ({ dispatch, get }) => {
  const api = get(ApiModule);
  const [user, todos] = await Promise.all([api.fetchUser(), api.fetchTodos()]);

  dispatch({ type: "APP_READY", payload: { user, todos } });
};

app.dispatch(initializeApp);

Modules — Dependency Injection That Doesn't Suck

Services, APIs, loggers — inject them once, use them everywhere. Child domains inherit from parents. The killer feature? Swap implementations per platform or environment without changing your business logic.

import { module } from "fluxdom";

// Define the module interface
interface Storage {
  get: (key: string) => Promise<string | null>;
  set: (key: string, value: string) => Promise<void>;
  remove: (key: string) => Promise<void>;
}

// Define the base module (throws if not overridden)
const StorageModule = module<Storage>("storage", () => {
  throw new Error("StorageModule not configured. Call app.override() first.");
});

// Web implementation
const WebStorage = module<Storage>("storage", () => ({
  get: async (key) => localStorage.getItem(key),
  set: async (key, value) => localStorage.setItem(key, value),
  remove: async (key) => localStorage.removeItem(key),
}));

// React Native implementation
const RNStorage = module<Storage>("storage", () => ({
  get: (key) => AsyncStorage.getItem(key),
  set: (key, value) => AsyncStorage.setItem(key, value),
  remove: (key) => AsyncStorage.removeItem(key),
}));

// Node.js / SSR implementation
const NodeStorage = module<Storage>("storage", () => ({
  get: async (key) => memoryCache.get(key) ?? null,
  set: async (key, value) => memoryCache.set(key, value),
  remove: async (key) => memoryCache.delete(key),
}));

Your business logic stays the same — everywhere:

// This code works on Web, React Native, and Node.js!
const saveUserPrefs = async ({ get }) => {
  const storage = get(StorageModule);
  await storage.set("theme", "dark");
  await storage.set("language", "en");
};

// Just wire up the right implementation at app startup
// web/index.ts
app.override(StorageModule, WebStorage);

// mobile/index.ts
app.override(StorageModule, RNStorage);

// server/index.ts
app.override(StorageModule, NodeStorage);

Environment-specific modules:

import { module } from "fluxdom";

interface Analytics {
  track: (event: string, data?: Record<string, any>) => void;
  identify: (userId: string) => void;
}

// Base definition
const AnalyticsModule = module<Analytics>("analytics", () => {
  throw new Error("AnalyticsModule not configured");
});

// Production: Real analytics
const AnalyticsProd = module<Analytics>("analytics", () => ({
  track: (event, data) => mixpanel.track(event, data),
  identify: (userId) => mixpanel.identify(userId),
}));

// Development: Just log to console
const AnalyticsDev = module<Analytics>("analytics", () => ({
  track: (event, data) => console.log("📊", event, data),
  identify: (userId) => console.log("👤", userId),
}));

// Wire up based on environment
if (process.env.NODE_ENV === "production") {
  app.override(AnalyticsModule, AnalyticsProd);
} else {
  app.override(AnalyticsModule, AnalyticsDev);
}

Testing? Mock anything:

const MockStorage = module<Storage>("storage", () => ({
  get: vi.fn().mockResolvedValue("mocked-value"),
  set: vi.fn().mockResolvedValue(undefined),
  remove: vi.fn().mockResolvedValue(undefined),
}));

const restore = app.override(StorageModule, MockStorage);
// ... run your tests ...
restore(); // Back to normal

Inheritance — child domains share parent's modules:

const api = app.get(ApiModule);
const sameApi = auth.get(ApiModule); // Same instance!

api === sameApi; // true — singleton per hierarchy

🚀 Advanced Usage

Multi-Store Selection

Why make two hooks when one will do?

const { todos, userName } = useSelector(
  [todoStore, userStore],
  (todos, user) => ({
    todos: todos.items,
    userName: user.name,
  })
);

Equality Strategies — Stop Unnecessary Renders

Objects and arrays creating render storms? Pick your weapon:

// Built-in strategies
const profile = useSelector(userStore, (s) => s.profile, "shallow");

| Strategy | Speed | Use When | | ------------ | ------ | ------------------------------------ | | "strict" | ⚡⚡⚡ | Primitives, immutable data (default) | | "shallow" | ⚡⚡ | Flat objects, arrays of primitives | | "shallow2" | ⚡ | Nested objects (1 level deep) | | "shallow3" | ⚡ | Nested objects (2 levels deep) | | "deep" | 🐢 | Complex nested structures |

// Or bring your own logic
const user = useSelector(
  userStore,
  (s) => s.profile,
  (a, b) => a.id === b.id // Only re-render if ID changes
);

Event Listeners — See Everything

Debug, log, analyze. Listen to actions flowing through your domains.

// Direct dispatches only
app.onDispatch(({ action, source }) => {
  console.log(`[${source}]`, action.type);
});

// EVERYTHING — including all children
app.onAnyDispatch(({ action, source }) => {
  analytics.track(action.type, { source });
});

Store-level too:

todoStore.onDispatch(({ action }) => {
  if (action.type === "ADD") {
    analytics.track("todo_created");
  }
});

Plugins — Extend Everything

The .use() method lets you enhance any store or domain.

// Add convenience methods
const todos = todoStore.use((store) => ({
  ...store,
  add: (text: string) => store.dispatch({ type: "ADD", text }),
  toggle: (id: number) => store.dispatch({ type: "TOGGLE", id }),
}));

// Now you can do this:
todos.add("Buy milk");
todos.toggle(123);
// Side-effect plugins (logging, persistence, etc.)
todoStore.use((store) => {
  store.onChange(() => {
    localStorage.setItem("todos", JSON.stringify(store.getState()));
  });
});

Domain Plugins — Hook Into Creation

Use domain.plugin() to intercept domain, store, and module creation. Perfect for logging, DevTools integration, persistence, and more.

import { domain } from "fluxdom";

// Create a domain with logging plugin
const app = domain("app").plugin({
  store: {
    pre: (config) => {
      console.log("[store:pre]", config.name);
      // Return new config to transform, or void to keep original
    },
    post: (store, config) => {
      console.log("[store:post]", store.name, config.initial);
      // Side effects only - must return void
    },
  },
  domain: {
    post: (d, config) => console.log("[domain:created]", d.name, config),
  },
  module: {
    post: (instance, def) => console.log("[module:created]", def.name),
  },
});

Plugin Hooks

| Hook | Signature | Purpose | | -------- | ---------------------------- | --------------------------------------------------------------- | | filter | (config) => boolean | Skip hooks if returns false | | pre | (config) => Config \| void | Transform config before creation | | post | (instance, config) => void | Side effects after creation (receives both instance and config) |

Hook Targets

  • store: Called for domain.store() and domain.model()
  • domain: Called for domain.domain() (subdomains)
  • module: Called for domain.get() (module instantiation)

Example: DevTools Integration

const app = domain("app").plugin({
  store: {
    post: (store) => {
      // Connect each store to Redux DevTools
      const devToolsExtension = window.__REDUX_DEVTOOLS_EXTENSION__;
      if (devToolsExtension) {
        const devTools = devToolsExtension.connect({ name: store.name });
        devTools.init(store.getState());
        store.onChange(() =>
          devTools.send({ type: "STATE_UPDATE" }, store.getState())
        );
      }
    },
  },
});

Example: Auto-Persistence with Meta Filter

Use the meta system to selectively apply plugins. First, augment the meta interface:

// In your app's types file
declare module "fluxdom" {
  interface StoreMeta {
    persisted?: boolean;
  }
}

Then create a persistence plugin that only applies to stores with meta.persisted:

// Usage - plugin is chainable
const app = domain("app").plugin({
  store: {
    // Only apply to stores with meta.persisted = true
    filter: (config) => config.meta?.persisted === true,
    pre: (config) => {
      // Hydrate from localStorage if available
      const saved = localStorage.getItem(config.name);
      if (saved) {
        return { ...config, initial: JSON.parse(saved) };
      }
    },
    post: (store, config) => {
      // Persist on every change
      store.onChange(() => {
        localStorage.setItem(store.name, JSON.stringify(store.getState()));
      });
    },
  },
});

// This store will be persisted
const userStore = app.store({
  name: "user",
  initial: { name: "" },
  reducer: userReducer,
  meta: { persisted: true },
});

// This store will NOT be persisted (no meta.persisted)
const tempStore = app.store({
  name: "temp",
  initial: {},
  reducer: tempReducer,
});

Plugin Inheritance

Plugins are automatically inherited by child domains:

const app = domain("app").plugin({
  store: { post: (s) => console.log("Store created:", s.name) },
});
const feature = app.domain("feature"); // Inherits plugin
feature.store({ ... }); // plugin hooks are called

Batched Hooks

Multiple .plugin() calls batch hooks - all pre hooks run in order, then the operation, then all post hooks in order:

const app = domain("app")
  .plugin({
    store: {
      pre: () => console.log("p1:pre"),
      post: () => console.log("p1:post"),
    },
  })
  .plugin({
    store: {
      pre: () => console.log("p2:pre"),
      post: () => console.log("p2:post"),
    },
  });

app.store({ name: "test", initial: 0, reducer: (s) => s });
// Logs: p1:pre → p2:pre → create → p1:post → p2:post

Batching — Optimize Multiple Updates

When dispatching many actions at once, use batch() to consolidate notifications. Instead of triggering listeners after each dispatch, notifications are deferred until the batch completes.

import { batch } from "fluxdom";

// Without batch: 3 notifications (one per dispatch)
counterStore.dispatch({ type: "INC" });
counterStore.dispatch({ type: "INC" });
counterStore.dispatch({ type: "INC" });

// With batch: 1 notification (after all dispatches)
batch(() => {
  counterStore.dispatch({ type: "INC" });
  counterStore.dispatch({ type: "INC" });
  counterStore.dispatch({ type: "INC" });
});

Why batch?

  • Performance: Reduce re-renders in React apps
  • Consistency: Listeners see final state, not intermediate states
  • Coordination: Update multiple stores atomically
// Update multiple stores, one notification per store
batch(() => {
  userStore.dispatch({ type: "SET_NAME", name: "Alice" });
  settingsStore.dispatch({ type: "SET_THEME", theme: "dark" });
  counterStore.dispatch({ type: "SET", value: 100 });
});

// State is updated synchronously during batch
batch(() => {
  counterStore.dispatch({ type: "INC" });
  console.log(counterStore.getState()); // 1 (updated immediately)
  counterStore.dispatch({ type: "INC" });
  console.log(counterStore.getState()); // 2 (updated immediately)
  // But listeners fire AFTER this block completes
});

Nested batches work correctly:

batch(() => {
  store.dispatch({ type: "A" });

  batch(() => {
    store.dispatch({ type: "B" });
    store.dispatch({ type: "C" });
  });

  store.dispatch({ type: "D" });
  // No notifications yet — outer batch still active
});
// NOW all notifications fire

Return values are passed through:

const result = batch(() => {
  counterStore.dispatch({ type: "INC" });
  counterStore.dispatch({ type: "INC" });
  return counterStore.getState();
});

console.log(result); // 2

Event Emitter — Roll Your Own Pub/Sub

A tiny, powerful emitter for custom event systems.

import { emitter } from "fluxdom";

const clicks = emitter<{ x: number; y: number }>();

// Subscribe
const unsub = clicks.on((pos) => console.log(`Clicked at ${pos.x}, ${pos.y}`));

// Emit
clicks.emit({ x: 100, y: 200 });

// Done
unsub();

Filter & transform on the fly:

const events = emitter<{ type: string; data: any }>();

// Only listen to errors
events.on(
  (e) => (e.type === "ERROR" ? { value: e.data } : undefined),
  (error) => console.error("💥", error)
);

Models — Stores with Bound Methods

Models are a higher-level abstraction that combines stores with bound action and effect methods. Instead of manually dispatching actions, you call methods directly on the model.

The key insight: Models ARE stores. They extend MutableStore, so they work everywhere stores work — useSelector, derived(), plugins, etc.

import { domain } from "fluxdom";

const app = domain("app");

// Create a model with bound methods
const counter = app.model({
  name: "counter",
  initial: 0,
  actions: (ctx) => ({
    increment: (state) => state + 1,
    decrement: (state) => state - 1,
    add: (state, amount: number) => state + amount,
    reset: ctx.reducers.reset, // Built-in reducer helper
    set: ctx.reducers.set, // Built-in reducer helper
  }),
});

// Call methods directly — no dispatch needed!
counter.increment();
counter.add(5);
counter.reset();

// Model IS a store — use it anywhere
counter.getState(); // 0
counter.onChange(() => {}); // Subscribe
counter.dispatch({ type: "increment", args: [] }); // Still works!

// Works with useSelector (because model IS a store)
const count = useSelector(counter);

// Works with derived()
const doubled = app.derived("doubled", [counter], (n) => n * 2);

With async effects and the task() helper:

const todos = app.model({
  name: "todos",
  initial: { items: [], loading: false, error: null },
  actions: (ctx) => ({
    setLoading: (state, loading: boolean) => ({ ...state, loading }),
    setItems: (state, items: Todo[]) => ({ ...state, items, loading: false }),
    setError: (state, error: string) => ({ ...state, error, loading: false }),
    reset: ctx.reducers.reset,
  }),
  // Effects receive full context — use task() for async lifecycle management
  effects: ({ task, actions, dispatch, getState, domain, initial }) => ({
    // task() wraps async operations with lifecycle dispatching
    fetchTodos: task(
      async () => {
        const api = domain.get(ApiModule);
        return await api.fetchTodos();
      },
      {
        start: () => actions.setLoading(true), // Before async starts
        done: (items) => actions.setItems(items), // On success
        fail: (err) => actions.setError(err.message), // On error (re-throws)
        end: (err, result) => actions.setLoading(false), // Always runs
      }
    ),

    // Regular effects without task() — manual control
    resetToInitial: () => {
      dispatch(actions.reset());
    },
    addIfNotLoading: (item: Todo) => {
      if (!getState().loading) {
        dispatch(actions.setItems([...getState().items, item]));
      }
    },
  }),
});

// Call effects directly - they're just regular methods
await todos.fetchTodos();
todos.resetToInitial();
todos.addIfNotLoading({ id: 1, title: "New" });

Using task() with promises inline:

effects: ({ task, actions }) => ({
  quickFetch: async () => {
    // Wrap any promise with lifecycle dispatching
    const data = await task(
      fetch("/api/data").then((r) => r.json()),
      {
        done: (d) => actions.setData(d),
        fail: (e) => actions.setError(e.message),
      }
    );
    return data;
  },
});

Callbacks can return void for listener-only behavior:

effects: ({ task, actions, domain }) => ({
  syncData: task(
    async () => { ... },
    {
      // Return Action → auto-dispatched to this model
      done: (data) => actions.setData(data),

      // Return void → listener only, manual control
      start: () => {
        console.log("Syncing...");
        domain.dispatch({ type: "SYNC_START" });
        otherModel.setLoading(true);
      },
      end: (err, result) => {
        console.log(err ? "Failed" : "Success", result);
      },
    }
  ),
});

Handle domain actions with fallback:

const app = domain("app");

const counter = app.model({
  name: "counter",
  initial: 0,
  // Fallback builder with ctx.on()
  fallback: (ctx) => {
    ctx.on((state, action) => {
      if (action.type === "RESET_ALL") return 0;
      if (action.type === "LOGOUT") return 0;
      return state;
    });
    // Can reuse action handlers via ctx.reducers
    // ctx.on((state, action) => action.type === "RESET" ? ctx.reducers.reset(state) : state);
  },
  actions: (actionsCtx) => ({
    increment: (state) => state + 1,
    reset: actionsCtx.reducers.reset,
  }),
});

// Domain action resets the counter
app.dispatch({ type: "RESET_ALL" });

With custom equality:

const user = app.model({
  name: "user",
  initial: { id: 0, name: "", profile: { bio: "" } },
  actions: () => ({
    setName: (state, name: string) => ({ ...state, name }),
  }),
  equals: "shallow", // Only notify if top-level properties change
});

Immer Integration — Mutable Syntax, Immutable Results

Tired of spread operators? Wrap your reducer with Immer's produce:

import { domain, actions } from "fluxdom";
import { produce } from "immer";

const app = domain("app");

const todoActions = actions({
  add: (text: string) => text,
  toggle: (id: number) => id,
  clear: true,
});

// Wrap reducer with produce - now you can mutate!
const reducer = actions.reducer(
  todoActions,
  produce((state: TodoState, action) => {
    switch (action.type) {
      case "add":
        state.items.push({ id: Date.now(), text: action.payload, done: false });
        break;
      case "toggle":
        const item = state.items.find((t) => t.id === action.payload);
        if (item) item.done = !item.done;
        break;
      case "clear":
        state.items = state.items.filter((t) => !t.done);
        break;
    }
  })
);

const store = app.store({ name: "todos", initial: { items: [] }, reducer });
store.dispatch(todoActions.add("Buy milk"));

Note: Install Immer separately: npm install immer


📖 API Reference

domain(name)

Create a root domain — your app's command center.

import { domain } from "fluxdom";

const app = domain("app");

module(name, create)

Define a module with type inference. Modules are lazy-loaded singletons that can be overridden per platform or for testing.

import { module } from "fluxdom";

// Basic module
const LoggerModule = module("logger", () => ({
  info: (msg: string) => console.log("INFO:", msg),
  warn: (msg: string) => console.warn("WARN:", msg),
  error: (msg: string) => console.error("ERROR:", msg),
}));

// Typed module with interface
interface HttpClient {
  get: <T>(url: string) => Promise<T>;
  post: <T>(url: string, body: unknown) => Promise<T>;
}

const HttpModule = module<HttpClient>("http", () => ({
  get: (url) => fetch(url).then((r) => r.json()),
  post: (url, body) =>
    fetch(url, {
      method: "POST",
      body: JSON.stringify(body),
      headers: { "Content-Type": "application/json" },
    }).then((r) => r.json()),
}));

// Module with domain access (for logging, nested modules, etc.)
const ApiModule = module("api", (domain) => {
  const http = domain.get(HttpModule);
  const logger = domain.get(LoggerModule);

  return {
    fetchUsers: async () => {
      logger.info("Fetching users...");
      return http.get("/api/users");
    },
  };
});

// Abstract module (must be overridden)
const StorageModule = module<Storage>("storage", () => {
  throw new Error("StorageModule not configured");
});

// Usage
const logger = app.get(LoggerModule);
const api = app.get(ApiModule);

domain.plugin(config)

Register a plugin that hooks into store, domain, and module methods. Returns the domain for chaining.

interface DomainPluginConfig {
  domain?: {
    filter?: (config: DomainConfig) => boolean;
    pre?: (config: DomainConfig) => DomainConfig | void;
    post?: (domain: Domain, config: DomainConfig) => void;
  };
  store?: {
    filter?: (config: StoreConfig<any, any>) => boolean;
    pre?: (config: StoreConfig<any, any>) => StoreConfig<any, any> | void;
    post?: (
      store: MutableStore<any, any>,
      config: StoreConfig<any, any>
    ) => void;
  };
  module?: {
    filter?: (definition: ModuleDef<any>) => boolean;
    pre?: (definition: ModuleDef<any>) => ModuleDef<any> | void;
    post?: (instance: any, definition: ModuleDef<any>) => void;
  };
}

interface DomainConfig {
  name: string;
  meta?: DomainMeta;
}

Key rules:

  • filter skips pre/post hooks if returns false (useful with meta system)
  • pre hooks can return new config (transform) or void (keep original)
  • post hooks receive both the instance and config; must return void (side effects only)
  • All hooks are synchronous
  • Plugins are inherited by child domains
  • Multiple .plugin() calls batch hooks (p1.pre → p2.pre → create → p1.post → p2.post)
const app = domain("app").plugin({
  store: {
    pre: (config) => console.log("[store:pre]", config.name),
    post: (store, config) => console.log("[store:created]", store.name, config),
  },
});

domain.name

The domain's identifier string.

const app = domain("app");
const auth = app.domain("auth");

console.log(app.name); // "app"
console.log(auth.name); // "app.auth"

domain.root

Reference to the root domain of the hierarchy.

const app = domain("app");
const auth = app.domain("auth");
const login = auth.domain("login");

login.root === app; // true
auth.root === app; // true
app.root === app; // true (root references itself)

domain.store(config)

Create a state store with a reducer function. Returns a MutableStore.

interface StoreConfig<TState, TAction> {
  name: string;
  initial: TState;
  reducer: (state: TState, action: TAction) => TState;
  equals?: Equality<TState>; // Optional equality for change detection
}
const counterStore = app.store({
  name: "counter",
  initial: 0,
  reducer: (state, action) => {
    switch (action.type) {
      case "INC":
        return state + 1;
      case "DEC":
        return state - 1;
      default:
        return state;
    }
  },
});

counterStore.dispatch({ type: "INC" });

With custom equality:

const settingsStore = app.store({
  name: "settings",
  initial: { theme: "dark", fontSize: 14 },
  reducer: settingsReducer,
  equals: "shallow", // Only notify if properties change
});

actions(definitions)

Create action creators from a definition map.

import { actions } from "fluxdom";

const counterActions = actions({
  // true = no payload, type = key name
  increment: true,

  // string = no payload, custom type (use `as const`!)
  decrement: "COUNTER_DEC" as const,

  // function = with payload (prepare function)
  incrementBy: (n: number) => n,
  addTodo: (text: string) => ({ id: Date.now(), text, done: false }),

  // object = custom type + prepare function
  set: { type: "SET" as const, prepare: (value: number) => value },
});

// Usage
counterActions.increment(); // { type: "increment", payload: undefined }
counterActions.decrement(); // { type: "COUNTER_DEC", payload: undefined }
counterActions.incrementBy(5); // { type: "incrementBy", payload: 5 }
counterActions.set(10); // { type: "SET", payload: 10 }

// Each has .type property
counterActions.increment.type; // "increment"
counterActions.set.type; // "SET"

// Each has .match() for type narrowing
if (counterActions.incrementBy.match(action)) {
  console.log(action.payload); // typed as number
}

📝 Note: When using custom string types, add as const for proper type inference:

// ❌ Without `as const` - type is `string`
const bad = actions({ reset: "RESET" });

// ✅ With `as const` - type is `"RESET"`
const good = actions({ reset: "RESET" as const });

actions.reducer(actionsInput, reducer)

Create a typed reducer from action creators. The action type is automatically inferred.

import { actions } from "fluxdom";

const counterActions = actions({
  increment: true,
  incrementBy: (n: number) => n,
});

// Single action source
const reducer = actions.reducer(counterActions, (state: number, action) => {
  switch (action.type) {
    case "increment":
      return state + 1;
    case "incrementBy":
      return state + action.payload; // payload typed!
    default:
      return state;
  }
});

const store = app.store({ name: "counter", initial: 0, reducer });

Combine multiple action sources:

const counterActions = actions({
  increment: true,
  set: (value: number) => value,
});

const domainActions = actions({
  resetAll: "RESET_ALL" as const,
  logout: true,
});

// Array of action sources - all types inferred!
const reducer = actions.reducer(
  [counterActions, domainActions],
  (state: number, action) => {
    switch (action.type) {
      case "increment":
        return state + 1;
      case "set":
        return action.payload;
      case "RESET_ALL":
        return 0;
      case "logout":
        return 0;
      default:
        return state;
    }
  }
);

Action type matching in listeners:

counterStore.onDispatch(({ action, source }) => {
  if (counterActions.increment.match(action)) {
    console.log(`[${source}] Incremented!`);
  }
  if (counterActions.incrementBy.match(action)) {
    console.log(`[${source}] Added:`, action.payload);
  }
});

---

#### Store state types

State can be any type — primitives, objects, arrays.

```ts
// Primitive state (number, string, boolean, etc.)
const counterActions = actions({ inc: true });
const counterReducer = actions.reducer(counterActions, (s: number, a) =>
  a.type === "inc" ? s + 1 : s
);
const counterStore = app.store({ name: "counter", initial: 0, reducer: counterReducer });

// Object state
interface UserState {
  name: string;
  email: string;
  loggedIn: boolean;
}

const userActions = actions({
  login: (payload: { name: string; email: string }) => payload,
  logout: true,
});

const userReducer = actions.reducer(userActions, (state: UserState, action) => {
  switch (action.type) {
    case "login":
      return { ...action.payload, loggedIn: true };
    case "logout":
      return { name: "", email: "", loggedIn: false };
    default:
      return state;
  }
});

const userStore = app.store({
  name: "user",
  initial: { name: "", email: "", loggedIn: false },
  reducer: userReducer,
});

// Store name is namespaced
console.log(counterStore.name); // "app.counter"

Custom equality for change detection:

The optional equals parameter controls when onChange listeners are notified. By default, strict reference equality (===) is used. Use custom equality for objects that should be compared by value.

// With shallow equality — onChange only fires if object properties differ
const settingsStore = app.store(
  "settings",
  { theme: "dark", fontSize: 14 },
  settingsReducer,
  "shallow" // Uses shallowEqual
);

// With deep equality — for nested objects
const configStore = app.store(
  "config",
  { ui: { sidebar: true }, api: { timeout: 5000 } },
  configReducer,
  "deep" // Uses deepEqual
);

// With custom equality function
const userStore = app.store(
  "user",
  { id: 1, name: "John", lastSeen: new Date() },
  userReducer,
  (prev, next) => prev.id === next.id && prev.name === next.name // Ignore lastSeen changes
);

| Equality | When to use | | ------------------- | --------------------------------------------------- | | "strict" | Primitives, immutable data (default) | | "shallow" | Flat objects where you always return new references | | "deep" | Nested objects (slower, use sparingly) | | (a, b) => boolean | Custom logic (e.g., compare only specific fields) |


domain.model(config)

Create a model — a store with bound action and effect methods. Models ARE stores, so they work with useSelector, derived(), and any store-based API.

interface ModelConfig<TState, TActionMap, TEffectsMap> {
  name: string;
  initial: TState;
  actions: (ctx: ModelActionContext<TState>) => TActionMap;
  fallback?: (ctx: FallbackContext<TState>) => void; // Handle domain actions
  effects?: (ctx: ModelEffectsContext<TState, TActionMap>) => TEffectsMap;
  equals?: Equality<TState>;
}

interface ModelActionContext<TState> {
  reducers: {
    reset: (state: TState) => TState; // Returns initial state
    set: (state: TState, value: TState) => TState; // Returns the new value
  };
}

interface FallbackContext<TState> {
  reducers: TActionMap; // Reuse action handlers from actions builder
  on(handler: (state: TState, action: AnyAction) => TState): void; // Catch-all
  on<TAction>(action: ActionMatcher<TAction>, handler: ...): void; // Single action
  on<TAction>(actions: ActionMatcher<TAction>[], handler: ...): void; // Multiple
}

interface ModelEffectsContext<TState, TActionMap> {
  task: TaskHelper; // Wrap async operations with lifecycle dispatching
  actions: ActionCreators<TActionMap>; // Type-safe action creators
  initial: TState; // The initial state value
  dispatch: Dispatch; // Dispatch actions to this model's store
  getState: () => TState; // Get current state (always fresh)
  domain: Domain; // Parent domain (for modules, other stores)
}

// TaskHelper accepts any PromiseLike (native Promises, Bluebird, jQuery Deferreds, etc.)
interface TaskHelper {
  <TResult>(
    promise: PromiseLike<TResult>,
    options: TaskOptions<TResult>
  ): Promise<TResult>;
  <TArgs extends any[], TResult>(
    fn: (...args: TArgs) => PromiseLike<TResult>,
    options: TaskOptions<TResult>
  ): (...args: TArgs) => Promise<TResult>;
}

// Each callback can return Action (auto-dispatched) or void (listener only)
interface TaskOptions<TResult, TError = Error> {
  start?: () => Action | void; // Before async starts
  done?: (result: TResult) => Action | void; // On success
  fail?: (error: TError) => Action | void; // On failure (error re-thrown)
  end?: (
    error: TError | undefined,
    result: TResult | undefined
  ) => Action | void; // Always runs
}
const counter = app.model({
  name: "counter",
  initial: 0,
  actions: (ctx) => ({
    increment: (state) => state + 1,
    add: (state, n: number) => state + n,
    reset: ctx.reducers.reset,
    set: ctx.reducers.set,
  }),
  // Effects: context (task, actions, dispatch, getState, domain) captured in closure
  effects: ({ task, actions, dispatch }) => ({
    // Using task() for lifecycle management
    incrementAsync: task(
      async () => {
        await delay(100);
        return 1;
      },
      { done: (n) => actions.add(n) }
    ),
    // Manual control without task()
    resetLater: async () => {
      await delay(100);
      dispatch(actions.reset());
    },
  }),
  equals: "strict", // Optional equality strategy
});

// Bound action methods
counter.increment();
counter.add(5);
counter.reset();
counter.set(100);

// Bound effect methods
await counter.incrementAsync();

// Store properties (model IS a store)
counter.name; // "app.counter"
counter.getState(); // number
counter.onChange(fn); // Subscribe
counter.onDispatch(fn); // Listen to actions
counter.dispatch(action); // Manual dispatch
counter.use(plugin); // Extend with plugin

Using with React:

// Model works directly with useSelector
function Counter() {
  const count = useSelector(counter);
  return <button onClick={() => counter.increment()}>{count}</button>;
}

domain.domain(name)

Create a child domain that inherits modules from its parent.

const app = domain("app");

// Create feature domains
const auth = app.domain("auth");
const user = app.domain("user");
const settings = user.domain("settings"); // Nested: "app.user.settings"

// Child inherits parent's modules
const api = app.get(ApiModule);
const sameApi = auth.get(ApiModule);
api === sameApi; // true

domain.derived(name, deps, selector, equals?)

Create a computed store that auto-updates when dependencies change.

const priceStore = app.store({
  name: "price",
  initial: 100,
  reducer: priceReducer,
});
const quantityStore = app.store({
  name: "quantity",
  initial: 2,
  reducer: quantityReducer,
});

// Derived store computes from multiple stores
const totalStore = app.derived(
  "total",
  [priceStore, quantityStore],
  (price, quantity) => ({
    total: price * quantity,
    formatted: `$${(price * quantity).toFixed(2)}`,
  })
);

totalStore.getState(); // { total: 200, formatted: "$200.00" }

// Auto-updates when dependencies change
priceStore.dispatch({ type: "SET", value: 150 });
totalStore.getState(); // { total: 300, formatted: "$300.00" }

With custom equality:

// Only notify listeners when specific fields change
const userSummary = app.derived(
  "userSummary",
  [userStore],
  (user) => ({ name: user.name, role: user.role }),
  "shallow" // Prevent updates if name and role are the same
);

// Custom equality function
const expensiveComputation = app.derived(
  "computed",
  [dataStore],
  (data) => computeExpensiveValue(data),
  (prev, next) => prev.id === next.id // Only recompute if ID changes
);

domain.dispatch(action | thunk)

Dispatch an action to all stores in this domain, or execute a thunk.

// Dispatch action — broadcasts to all stores in domain
app.dispatch({ type: "RESET" });

// Dispatch thunk — for async operations
app.dispatch(async ({ dispatch, get }) => {
  const api = get(ApiModule);
  const data = await api.fetchConfig();
  dispatch({ type: "CONFIG_LOADED", payload: data });
});

// Thunks can return values
const result = app.dispatch(({ get }) => {
  const api = get(ApiModule);
  return api.getVersion();
});

domain.get(moduleDef)

Resolve a module by its definition. Lazy-loads on first access, then cached.

import { module } from "fluxdom";

// Define module with the helper
const LoggerModule = module("logger", (domain) => ({
  info: (msg: string) => console.log(`[${domain.name}] INFO:`, msg),
  error: (msg: string) => console.error(`[${domain.name}] ERROR:`, msg),
}));

// Resolve (created on first call)
const logger = app.get(LoggerModule);
logger.info("App started"); // "[app] INFO: App started"

// Same instance returned on subsequent calls
app.get(LoggerModule) === logger; // true

// Child domains inherit parent's instances
auth.get(LoggerModule) === logger; // true

domain.override(source, replacement)

Override a module for testing or platform-specific implementations. Returns a function to restore the original.

import { module } from "fluxdom";

interface Api {
  fetchUser: () => Promise<{ id: number; name: string }>;
}

// Base module definition
const ApiModule = module<Api>("api", () => {
  throw new Error("ApiModule not configured");
});

// Production implementation
const ApiProd = module<Api>("api", () => ({
  fetchUser: () => fetch("/api/user").then((r) => r.json()),
}));

// Mock for testing
const ApiMock = module<Api>("api", () => ({
  fetchUser: () => Promise.resolve({ id: 1, name: "Test User" }),
}));

// Override before tests
const restore = app.override(ApiModule, ApiMock);

// Now app.get(ApiModule) returns the mock
const api = app.get(ApiModule);
await api.fetchUser(); // { id: 1, name: "Test User" }

// Restore after tests
restore();

domain.onDispatch(fn)

Listen to actions dispatched directly to this domain.

const unsub = app.onDispatch(({ action, source, context }) => {
  console.log(`Action: ${action.type}`);
  console.log(`Source: ${source}`);
  console.log(`Can dispatch more:`, typeof context.dispatch === "function");
});

app.dispatch({ type: "TEST" });
// Logs: Action: TEST, Source: app

unsub(); // Stop listening

domain.onAnyDispatch(fn)

Listen to ALL actions — from this domain AND all descendants (stores, sub-domains).

// Perfect for logging, analytics, debugging
const unsub = app.onAnyDispatch(({ action, source }) => {
  analytics.track("action", {
    type: action.type,
    source: source,
    timestamp: Date.now(),
  });
});

// Catches everything
app.dispatch({ type: "APP_ACTION" }); // source: "app"
counterStore.dispatch({ type: "INC" }); // source: "app.counter"
auth.dispatch({ type: "LOGIN" }); // source: "app.auth"

unsub();

domain.use(plugin)

Extend the domain with a plugin. Returns the enhanced domain or the plugin's return value.

// Add helper methods
const enhancedApp = app.use((domain) => ({
  ...domain,
  reset: () => domain.dispatch({ type: "RESET" }),
  log: (msg: string) => console.log(`[${domain.name}]`, msg),
}));

enhancedApp.reset();
enhancedApp.log("Hello!"); // "[app] Hello!"

Store

A mutable store created via domain.store().


store.name

The store's namespaced identifier.

const counterStore = app.store({ name: "counter", initial: 0, reducer });
console.log(counterStore.name); // "app.counter"

store.getState()

Get the current state snapshot.

const counterStore = app.store({ name: "counter", initial: 0, reducer });

console.log(counterStore.getState()); // 0

counterStore.dispatch({ type: "INC" });
console.log(counterStore.getState()); // 1

store.dispatch(action | thunk)

Dispatch an action or execute a thunk with store context.

// Dispatch action
counterStore.dispatch({ type: "INC" });

// Dispatch thunk with state access
counterStore.dispatch(({ dispatch, getState }) => {
  if (getState() < 10) {
    dispatch({ type: "INC" });
  }
});

// Async thunk
counterStore.dispatch(async ({ dispatch, domain }) => {
  const api = domain.get(ApiModule);
  const value = await api.fetchCount();
  dispatch({ type: "SET", value });
});

store.onChange(fn)

Subscribe to state changes. Called after every state update.

const unsub = counterStore.onChange(() => {
  console.log("New state:", counterStore.getState());
});

counterStore.dispatch({ type: "INC" }); // Logs: "New state: 1"
counterStore.dispatch({ type: "INC" }); // Logs: "New state: 2"

unsub(); // Stop listening

store.onDispatch(fn)

Subscribe to all actions dispatched to this store (including domain-level actions).

const unsub = counterStore.onDispatch(({ action, source, context }) => {
  console.log(`[${source}] ${action.type}`);

  // Access current state
  console.log("State:", context.getState());

  // Can dispatch more actions (be careful of loops!)
  if (action.type === "INC" && context.getState() > 100) {
    context.dispatch({ type: "SET", value: 0 });
  }
});

counterStore.dispatch({ type: "INC" });
// Logs: "[app.counter] INC", "State: 1"

unsub();

store.use(plugin)

Extend the store with a plugin.

// Create a typed API around the store
const counter = counterStore.use((store) => ({
  get value() {
    return store.getState();
  },
  inc: () => store.dispatch({ type: "INC" }),
  dec: () => store.dispatch({ type: "DEC" }),
  set: (n: number) => store.dispatch({ type: "SET", value: n }),
}));

counter.inc();
counter.inc();
console.log(counter.value); // 2
counter.set(0);
console.log(counter.value); // 0

DerivedStore

A read-only computed store created via domain.derived().


derivedStore.name

The derived store's namespaced identifier.

const stats = todos.derived("stats", [todoStore], selector);
console.log(stats.name); // "app.todos.stats"

derivedStore.dependencies

Array of source stores this derived store depends on.

const total = app.derived("total", [priceStore, quantityStore], selector);

console.log(total.dependencies); // [priceStore, quantityStore]
console.log(total.dependencies.length); // 2

derivedStore.getState()

Get the current computed value.

const stats = todos.derived("stats", [todoStore], (todos) => ({
  total: todos.items.length,
  done: todos.items.filter((t) => t.done).length,
}));

console.log(stats.getState()); // { total: 5, done: 2 }

// Automatically recomputes when todoStore changes
todoStore.dispatch({ type: "ADD", text: "New task" });
console.log(stats.getState()); // { total: 6, done: 2 }

derivedStore.onChange(fn)

Subscribe to changes in the computed value.

const stats = todos.derived("stats", [todoStore], selector);

const unsub = stats.onChange(() => {
  console.log("Stats updated:", stats.getState());
});

todoStore.dispatch({ type: "ADD", text: "Task" });
// Logs: "Stats updated: { total: 1, done: 0 }"

unsub();

useSelector(store, selector?, equality?)

React hook — subscribe to store state with surgical precision.

import { useSelector } from "fluxdom/react";

// Full state (no selector) — works great with primitive state
function Counter() {
  const count = useSelector(counterStore);
  return <span>{count}</span>;
}

// With selector — extract specific data from object state
function UserName() {
  const name = useSelector(userStore, (s) => s.name);
  return <span>{name}</span>;
}

// With equality check — prevent re-renders for equivalent objects
function UserProfile() {
  const profile = useSelector(userStore, (s) => s.profile, "shallow");
  return <div>{profile.name}</div>;
}

// Custom equality function
function UserAvatar() {
  const user = useSelector(
    userStore,
    (s) => ({ id: s.id, avatar: s.avatar }),
    (a, b) => a.id === b.id && a.avatar === b.avatar
  );
  return <img src={user.avatar} />;
}

// Multiple stores
function Dashboard() {
  const data = useSelector(
    [todoStore, userStore],
    (todos, user) => ({
      userName: user.name,
      taskCount: todos.items.length,
    }),
    "shallow"
  );

  return (
    <div>
      Welcome {data.userName}! You have {data.taskCount} tasks.
    </div>
  );
}

batch(fn)

Batch multiple dispatches into a single notification cycle. Notifications are deferred until the batch completes, then fire once per store.

import { batch } from "fluxdom";

// Batch multiple updates
batch(() => {
  storeA.dispatch({ type: "UPDATE_A" });
  storeB.dispatch({ type: "UPDATE_B" });
  storeC.dispatch({ type: "UPDATE_C" });
});
// All onChange listeners fire AFTER this point

// Get return value from batch
const finalState = batch(() => {
  store.dispatch({ type: "INC" });
  store.dispatch({ type: "INC" });
  return store.getState();
});

// Nested batches — notifications wait for outermost batch
batch(() => {
  store.dispatch({ type: "A" });
  batch(() => {
    store.dispatch({ type: "B" });
  }); // inner batch completes, but still in outer batch
  store.dispatch({ type: "C" });
}); // NOW notifications fire

// Works with async (but only batches sync portion)
await batch(async () => {
  store.dispatch({ type: "SYNC_1" }); // batched
  store.dispatch({ type: "SYNC_2" }); // batched

  await someAsyncOperation(); // batch ends here

  store.dispatch({ type: "AFTER_AWAIT" }); // NOT batched
});

Key behaviors:

  • State updates synchronously during batch (getState() always returns latest)
  • Notifications deferred until batch completes
  • Same callback function de-duplicated (each store notifies once, not once per dispatch)
  • Nested batches defer to outermost batch
  • Errors don't prevent queued notifications from firing
  • Return values pass through

Equality Utilities

Functions for comparing values. Used internally by useSelector.

import {
  strictEqual,
  shallowEqual,
  shallow2Equal,
  shallow3Equal,
  deepEqual,
  resolveEquality,
} from "fluxdom";

// strictEqual — Object.is comparison
strictEqual(1, 1); // true
strictEqual({}, {}); // false (different references)

// shallowEqual — compare object keys with Object.is
shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 }); // true
shallowEqual({ a: { x: 1 } }, { a: { x: 1 } }); // false (nested objects)

// shallow2Equal — 2 levels deep
shallow2Equal({ a: { x: 1 } }, { a: { x: 1 } }); // true
shallow2Equal({ a: { b: { x: 1 } } }, { a: { b: { x: 1 } } }); // false

// shallow3Equal — 3 levels deep
shallow3Equal({ a: { b: { x: 1 } } }, { a: { b: { x: 1 } } }); // true

// deepEqual — full recursive comparison
deepEqual({ a: { b: { c: { d: 1 } } } }, { a: { b: { c: { d: 1 } } } }); // true

// resolveEquality — convert shorthand to function
const eq = resolveEquality("shallow");
eq({ a: 1 }, { a: 1 }); // true

withUse(object)

Add chainable .use() method to any object.

import { withUse } from "fluxdom";

// Make any object chainable
const api = withUse({
  baseUrl: "https://api.example.com",
  fetch: (path: string) => fetch(`https://api.example.com${path}`),
});

// Extend with plugins
const enhancedApi = api
  .use((api) => ({
    ...api,
    getUsers: () => api.fetch("/users").then((r) => r.json()),
  }))
  .use((api) => ({
    ...api,
    getTodos: () => api.fetch("/todos").then((r) => r.json()),
  }));

await enhancedApi.getUsers();
await enhancedApi.getTodos();

isPromiseLike(value)

Check if a value is a PromiseLike (has a .then method). Works with native Promises, Bluebird, jQuery Deferreds, and any thenable.

import { isPromiseLike } from "fluxdom";

isPromiseLike(Promise.resolve(1)); // true
isPromiseLike(fetch("/api")); // true
isPromiseLike({ then: (fn) => fn(42) }); // true (custom thenable)
isPromiseLike(() => {}); // false
isPromiseLike(null); // false
isPromiseLike(42); // false

matches(action, actionOrActions)

Check if an action matches one or more action creators. Useful for filtering in onDispatch / onAnyDispatch listeners. Provides type narrowing for matched actions.

import { actions, matches } from "fluxdom";

const todoActions = actions({
  add: (title: string) => ({ title }),
  remove: (id: number) => id,
  toggle: (id: number) => id,
});

// In dispatch listeners
app.onAnyDispatch(({ action }) => {
  // Single action — type narrowing works!
  if (matches(action, todoActions.add)) {
    console.log("Added:", action.payload.title); // ✅ typed as { title: string }
  }

  // Multiple actions
  if (matches(action, [todoActions.add, todoActions.remove])) {
    console.log("Todo list changed");
  }
});

// Works with prefixed actions too
const prefixedActions = actions("todos", {
  add: (title: string) => ({ title }),
});

if (matches(someAction, prefixedActions.add)) {
  // action.type is "todos/add"
}

🔷 TypeScript

FluxDom is built with TypeScript. Every type is exported:

import type {
  // Core types
  Action,
  Domain,
  Store,
  MutableStore,
  DerivedStore,
  Reducer,
  Thunk,
  DomainContext,
  StoreContext,
  ModuleDef,
  Equality,
  Emitter,

  // Action creator types
  ActionCreator,

  // Model types
  Model,
  ModelWithMethods,
  ModelConfig,
  ModelActionContext,
  ModelEffectsContext,
  FallbackContext,
  FallbackBuilder,
  ActionMatcher,
  TaskOptions,
  TaskHelper,

  // Store config
  StoreConfig,

  // Plugin types
  DomainPluginConfig,
  DomainConfig,

  // Meta types (augmentable interfaces)
  DomainMeta,
  StoreMeta,
  ModuleMeta,
} from "fluxdom";

// Functions
import {
  domain,
  actions, // Create action creators + actions.reducer()
  module,
  derived,
  emitter,
  batch,
  withUse,
  isPromiseLike, // Check if value is a PromiseLike/thenable
  matches, // Check if action matches action creator(s)
  strictEqual,
  shallowEqual,
  deepEqual,
  resolveEquality,
} from "fluxdom";

🔄 FluxDom vs Redux Toolkit

If you're coming from Redux/RTK, FluxDom will feel familiar — but with less ceremony.

Feature Comparison

| Feature | Redux Toolkit | FluxDom | | ------------------------ | ----------------------------- | ---------------------------------------- | | Slice/Model | createSlice() | domain.model() | | Async Thunks | createAsyncThunk() | task() in effects | | Store Setup | configureStore() + Provider | Just domain() — no providers | | Store Structure | Single global store | Multiple stores in hierarchical domains | | State Shape | Always an object | Any type (primitives, objects, arrays) | | Computed State | createSelector (Reselect) | Built-in domain.derived() | | Immer | Built-in | Works with Immer (optional) | | Middleware | Redux middleware | Domain plugins with pre/post hooks | | Dependency Injection | Manual / thunkAPI.extra | Built-in module() system | | Testing | Mock entire store | domain.override() for surgical mocking | | DevTools | Redux DevTools | onAnyDispatch() + plugins | | Code Splitting | Complex with replaceReducer | Natural with domain hierarchy | | Bundle Size | ~12kb (RTK core) | ~4kb |

createSlice vs model()

Redux Toolkit:

import { createSlice, configureStore } from "@reduxjs/toolkit";

const todosSlice = createSlice({
  name: "todos",
  initialState: { items: [], loading: false },
  reducers: {
    setLoading: (state, action) => {
      state.loading = action.payload;
    },
    setItems: (state, action) => {
      state.items = action.payload;
      state.loading = false;
    },
  },
});

const store = configureStore({ reducer: { todos: todosSlice.reducer } });
const { setLoading, setItems } = todosSlice.actions;

// Usage
store.dispatch(setLoading(true));
store.dispatch(setItems([{ id: 1, title: "Task" }]));

FluxDom:

import { domain } from "fluxdom";

const app = domain("app");

const todosModel = app.model({
  name: "todos",
  initial: { items: [], loading: false },
  actions: () => ({
    setLoading: (state, loading: boolean) => ({ ...state, loading }),
    setItems: (state, items) => ({ ...state, items, loading: false }),
  }),
});

// Usage — bound methods, no dispatch needed
todosModel.setLoading(true);
todosModel.setItems([{ id: 1, title: "Task" }]);

createAsyncThunk vs task()

Redux Toolkit:

import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";

const fetchTodos = createAsyncThunk(
  "todos/fetch",
  async (_, { rejectWithValue }) => {
    try {
      const res = await fetch("/api/todos");
      return res.json();
    } catch (err) {
      return rejectWithValue(err.message);
    }
  }
);

const todosSlice = createSlice({
  name: "todos",
  initialState: { items: [], loading: false, error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchTodos.pending, (state) => {
        state.loading = true;
      })
      .addCase(fetchTodos.fulfilled, (state, action) => {
        state.loading = false;
        state.items = action.payload;
      })
      .addCase(fetchTodos.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload;
      });
  },
});

// Usage
store.dispatch(fetchTodos());

FluxDom:

const todosModel = app.model({
  name: "todos",
  initial: { items: [], loading: false, error: null },
  actions: () => ({
    setLoading: (state, loading) => ({ ...state,