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 fluxdomNo 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()); // 1Works 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 HereWhy 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; // trueStores — 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 constfor proper type inference. Without it, the type is inferred asstringinstead of the literal type.
Benefits of actions():
- 🎯 Type-safe — Action types and payloads fully inferred
- 📦 Flexible definitions —
true,"TYPE",(args) => payload, or{ type, prepare } - 🔗 Composable — Combine store actions + domain actions
- 🔍 Matchable — Use
.typeand.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 normalInheritance — 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 fordomain.store()anddomain.model()domain: Called fordomain.domain()(subdomains)module: Called fordomain.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 calledBatched 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:postBatching — 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 fireReturn values are passed through:
const result = batch(() => {
counterStore.dispatch({ type: "INC" });
counterStore.dispatch({ type: "INC" });
return counterStore.getState();
});
console.log(result); // 2Event 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:
filterskips pre/post hooks if returns false (useful with meta system)prehooks can return new config (transform) or void (keep original)posthooks 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 constfor 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 pluginUsing 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; // truedomain.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; // truedomain.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 listeningdomain.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()); // 1store.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 listeningstore.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); // 0DerivedStore
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); // 2derivedStore.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 }); // truewithUse(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); // falsematches(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,