@bkincz/clutch
v3.3.1
Published
A TypeScript-first, plugin-based state management library built on Immer
Maintainers
Readme
Clutch
A TypeScript-first, plugin-based state manager built on Immer. The core does immutable updates and subscriptions. Everything else, like undo/redo, persistence, sync, and DevTools, is a plugin you opt into. You only ship what you use.
Coming from v2? See the migration guide.
Installation
pnpm add @bkincz/clutchnpm install @bkincz/clutchyarn add @bkincz/clutchQuick Start
import { createMachine } from '@bkincz/clutch'
interface AppState {
count: number
todos: string[]
}
const machine = createMachine<AppState>({
initialState: { count: 0, todos: [] },
})
// Write mutable-style code, get immutable state back
machine.mutate(draft => {
draft.count++
draft.todos.push('Learn Clutch')
})
machine.getState() // { count: 1, todos: ['Learn Clutch'] }
const unsubscribe = machine.subscribe(state => console.log(state))The core machine has mutate, set, batch, getState, subscribe, reset, and destroy. That is all. Features come from plugins.
// Hot paths can skip immer entirely: set() shallow-merges at plain-object
// speed and still feeds every plugin, so undo, persist, and sync keep working
machine.set({ count: 2 })
// Subscribe to a slice outside React; fires only when the selection changes
machine.subscribe(
state => state.count,
(count, previous) => console.log(previous, '->', count),
)Plugins
Install plugins with .with(). Each plugin adds its methods to the machine, and to its TypeScript type. Calling a method from a plugin you never installed is a compile error, not a runtime surprise.
import { createMachine, history, persist, devtools } from '@bkincz/clutch'
const machine = createMachine<AppState>({ initialState })
.with(history<AppState>({ maxSize: 50 }))
.with(persist<AppState>({ key: 'app' }))
.with(devtools<AppState>({ name: 'App' }))
machine.undo() // from history
machine.flush() // from persist| Plugin | What it adds |
|---|---|
| history() | Undo/redo with patch-based history |
| persist() | localStorage persistence with debounced writes |
| devtools() | Redux DevTools integration with time travel |
| sync() | State sync across tabs or via WebSocket |
| validate() | Rejects commits that fail your validator |
| autosave() | Periodic and manual saving to your server |
Plugin order matters in one case: install persist before sync so hydration happens before the first sync exchange.
history
machine.with(history<AppState>({ maxSize: 50 })) // default 50Adds undo(), redo(), canUndo(), canRedo(), getHistoryInfo(), and clearHistory(). Undo and redo return false when there is nothing to apply.
getHistoryInfo() returns { canUndo, canRedo, historyLength, currentIndex, lastAction } and is referentially stable between history changes, so it is safe to use as a useSyncExternalStore snapshot.
persist
machine.with(persist<AppState>({
key: 'app', // required, the storage key
debounceMs: 300, // default 300
maxChars: 5 * 1024 * 1024, // refuse to write larger payloads
storage: customStorage, // anything with getItem/setItem/removeItem
filter: { exclude: ['draft'] }, // or { include: [...] } or { custom: state => ... }
version: 2, // bump when the persisted shape changes
migrate: (old, from) => upgrade(old, from),
deferred: false, // see SSR below
}))Adds hydrate(), isHydrated(), flush(), and clearPersisted(). State is loaded on install and written on a debounce after every change. flush() forces a pending write immediately. Writes also flush on pagehide and destroy().
Versioning: state persisted under a different version runs through migrate(oldState, fromVersion) before hydrating. Without a migrate, mismatched state is discarded with a warning instead of hydrating a shape your code no longer expects. State persisted before you added version counts as version 0.
SSR: pass deferred: true to skip hydration on install, then call hydrate() on the client after mount (or use the useHydration React hook). Without a browser storage available the plugin is inert, so creating machines on the server is safe.
devtools
machine.with(devtools<AppState>({ name: 'App', maxAge: 50 }))Connects to the Redux DevTools Extension. Every commit, undo, sync update, and hydration shows up in the timeline. Time travel from the extension updates the machine. Adds no methods.
sync
machine.with(sync<AppState>({
channel: 'my-app', // BroadcastChannel name, for cross-tab sync
mergeStrategy: 'latest', // or 'patches'
syncDebounce: 50,
transport: customTransport, // bring your own, e.g. WebSocket
autoStart: true,
}))Adds startSync(). By default sync uses a BroadcastChannel and starts on install. For server sync, pass the WebSocket transport:
import { WebSocketTransport } from '@bkincz/clutch/sync-ws'
machine.with(sync<AppState>({
transport: new WebSocketTransport({ url: 'wss://example.com/sync' }),
}))With deferred persistence, pass autoStart: false and call startSync() after hydrate(), so a remote response cannot race your locally persisted state. The wire format is documented in docs/sync-protocol.md.
validate
machine.with(validate<AppState>(state => state.count >= 0))
// Return a string to reject with a message instead of the generic one
machine.with(validate<AppState>(state =>
state.count >= 0 ? true : `count went negative: ${state.count}`,
))A failing validator makes mutate, set, and batch throw before any state is applied. Updates arriving from outside (sync, hydration) are already applied when plugins see them, so those are reported through onError instead of rejected.
autosave
machine.with(autosave<AppState>({
save: state => api.put('/state', state), // required
load: () => api.get('/state'), // optional
intervalMs: 5 * 60 * 1000, // default 5 minutes
auto: true, // false = manual forceSave only
}))Adds forceSave(), hasUnsavedChanges(), setAutoSaveInterval(ms), and loadFromServer(). Only local mutations mark the state dirty. Undo and incoming sync updates do not, since that data is already saved somewhere. A failed save stays dirty and retries on the next interval.
Reset
machine.reset() returns to the initial state. History clears and persistence rewrites. Resets are not broadcast to sync peers.
Registry
Group machines and read them as one combined state:
import { createMachine, createRegistry, history, persist } from '@bkincz/clutch'
const registry = createRegistry({
user: createMachine<UserState>({ initialState: userInit }).with(history<UserState>()),
cart: createMachine<CartState>({ initialState: cartInit }).with(persist<CartState>({ key: 'cart' })),
})
registry.getState() // { user: UserState, cart: CartState }
registry.subscribe(combined => { ... })
registry.machines.user.undo() // typed, plugin methods are preserved per machineCoordination methods only reach machines that have the matching plugin installed:
| Method | Reaches |
|---|---|
| resetAll() | every machine |
| hydrateAll() / flushAll() | machines with persist |
| forceSaveAll() / hasUnsavedChanges() | machines with autosave |
| clearAllHistory() | machines with history |
| destroyAll() | every machine |
The machine map is fixed at creation. There is no register/unregister.
Performance
Reads are free: getState() returns the current reference, and useSlice only re-renders when the selected value changes. Writes go through immer, which buys draft ergonomics and patch-based plugins at a proxy cost. Measured on a small object (Node, ops/second):
| Operation | ops/s |
|---|---|
| set() | ~3,000,000 |
| set() with history recording | ~1,500,000 |
| mutate() without plugins | ~840,000 |
| mutate() with plugins | ~400,000 |
All of these are orders of magnitude past what a UI needs; a 60fps interaction writes hundreds of times per second, not thousands. For the places that do write in tight loops, in order of preference:
- Use
set()for shallow updates on hot paths (drag handlers, per-frame values). It skips immer, synthesizes patches per changed key, and every plugin still works. - Keep large collections as keyed objects rather than long arrays. Immer proxies what you touch; indexing into a big array touches more than you think.
- One
mutaterecipe that does five things beats five recipes.batchruns one produce per recipe.
Machines without plugins skip patch generation automatically.
Micro frontends
Independently deployed apps sharing one page need to share state without sharing builds. sharedMachine creates a machine once per page and returns the same instance to every caller of the same key, no matter which bundle the call comes from:
import { createMachine, persist, sharedMachine } from '@bkincz/clutch'
export const playerMachine = sharedMachine('app:player', () =>
createMachine<PlayerState>({ initialState }).with(persist<PlayerState>({ key: 'player' })),
)Each app keeps its own copy of this module, and they all end up on one machine. The registry lives on globalThis and instances are used structurally, so it works even when apps bundle separate copies of clutch. The React hooks take it from there: useSlice(playerMachine, s => s.track).
With Module Federation, share clutch as a singleton so only one copy loads:
// the shared option of your federation plugin, or "shared" in spool.json
"shared": ["react", "react-dom", "@bkincz/clutch", "@bkincz/clutch/react"]Independent deployments can drift: one team ships a new state shape while another app is still built against the old one. Declare a contract version and clutch warns at runtime when two apps disagree on it, naming the key and both versions. Mismatched clutch versions across bundles get the same warning.
sharedMachine('app:player', factory, { contract: 2 })For micro frontends in separate iframes or tabs there is no shared page to share an instance on; use the sync plugin instead, whose BroadcastChannel transport keeps same-origin machines in step.
See it live: Resonate, a music UI where the browse view, search, and player bar are separately deployed apps sharing one player machine. Built with spool.
React
Hooks live in @bkincz/clutch/react. The main entry stays React-free.
import { useMachine, useSlice } from '@bkincz/clutch/react'
function Counter() {
const { state, mutate } = useMachine(machine)
return <button onClick={() => mutate(d => { d.count++ })}>{state.count}</button>
}
function CountLabel() {
// re-renders only when the selected value changes
const count = useSlice(machine, s => s.count)
return <span>{count}</span>
}| Hook | Needs | Returns |
|---|---|---|
| useMachine(machine) | core | { state, mutate, batch } |
| useSlice(machine, selector, equalityFn?) | core | the selected value |
| useSubscription(machine, callback) | core | nothing, runs your callback on changes |
| useRegistry(registry) | registry | combined state |
| useRegistrySlice(registry, selector, equalityFn?) | registry | the selected value |
| useMachineHistory(machine) | history plugin | history info plus undo, redo, clearHistory |
| useHydration(machine) | persist plugin | { isHydrated }, hydrates on mount |
| useAutosave(machine) | autosave plugin | save, load, isSaving, saveError, hasUnsavedChanges, ... |
The plugin hooks require the plugin's methods on the machine type. Passing a machine without that plugin fails to compile.
Writing a Plugin
A plugin is an object with a name and lifecycle hooks. Whatever onInit returns is merged onto the machine and shows up in its type.
import type { Plugin } from '@bkincz/clutch'
function logger<T extends object>(): Plugin<T, { getLogCount(): number }> {
let count = 0
return {
name: 'logger',
onInit: () => ({ getLogCount: () => count }),
onCommit: payload => {
count++
console.log(payload.description ?? payload.operation, payload.patches)
},
}
}
const machine = createMachine({ initialState }).with(logger())
machine.getLogCount()Available hooks:
onInit(ctx)runs at install. Return an object to extend the machine.ctxgives yougetState,subscribe,replaceState,applyPatches, andemitError.onBeforeCommit(payload)runs before a mutation is applied. Throw to reject it.onCommit(payload)runs after a mutation is applied, before subscribers are notified. The payload has the new state, patches, inverse patches, description, and operation.onExternalState(state, meta)runs when state changes outside the mutation path: sync updates, hydration, undo, time travel, reset.meta.sourcenames the plugin that caused it. Your own changes viactx.replaceStateare not echoed back to you.onError(error, operation)receives errors from any plugin.onDestroy(finalState)runs onmachine.destroy(), in reverse install order.
Plugin names must be unique per machine, and extension keys must not collide with existing methods. Both throw at install time.
Migrating from v2
The v2 API and the createV2Machine bridge were removed in 3.0.0. The option-to-plugin mapping is in the migration guide.
TypeScript
State types are inferred from initialState, or pass them explicitly: createMachine<AppState>(...). Each .with() call widens the machine type with the plugin's API, so autocomplete always matches what is actually installed.
const machine = createMachine<AppState>({ initialState })
.with(history<AppState>())
machine.undo() // ok
machine.hydrate() // compile error, persist is not installedTo name the type of a plugin-extended machine, build it in a function and use ReturnType<typeof build>.
Bundle Size
Sizes are minified and brotli compressed, including Immer.
| Import | Size |
|---|---|
| { createMachine } only | ~4.8 KB |
| Everything | ~9.1 KB |
| React hooks (/react) | ~0.8 KB |
| WebSocket transport (/sync-ws) | ~1.1 KB |
Plugins you do not import are tree-shaken away.
License
MIT
