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

@bkincz/clutch

v3.3.1

Published

A TypeScript-first, plugin-based state management library built on Immer

Readme

Clutch

Release codecov npm version License: MIT

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/clutch
npm install @bkincz/clutch
yarn add @bkincz/clutch

Quick 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 50

Adds 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 machine

Coordination 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 mutate recipe that does five things beats five recipes. batch runs 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. ctx gives you getState, subscribe, replaceState, applyPatches, and emitError.
  • 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.source names the plugin that caused it. Your own changes via ctx.replaceState are not echoed back to you.
  • onError(error, operation) receives errors from any plugin.
  • onDestroy(finalState) runs on machine.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 installed

To 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