Skip to content

Release 0.3.0: action stores - #2

Merged
jaaamesey merged 9 commits into
mainfrom
claude/react-state-management-lib-jg8748
Oct 31, 2026
Merged

jaaamesey merged 9 commits into
mainfrom
claude/react-state-management-lib-jg8748

Conversation

@jaaamesey

@jaaamesey jaaamesey commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

What

Any store can now take an action set instead of a setter, for apps that want every change to go through named, typed, testable functions. Its actions are then the only way to write.

export const counterActionSet = defineActions({
  add(s: Counter, n: number) {
    s.count += n
  },
})

const [counter, counterActions] = useNewStore({ count: 0 }, counterActionSet)
counterActions.add(1)
  • Same APIs, one rule: useNewStore, createContextStore, createNarrowedStoreContext and useNarrowedStore each also accept an action set (or an action context). New exports are defineActions, withContext, createTestStore and four types.
  • Narrowing is declared with the actions. A set narrows to other sets (narrow((s, id: string) => s.todos.find(…), todoActionSet)), to any depth, recursive data included, so each part of the state only gets its own actions.
  • Context from above: a set declares what it needs (a page's size, an undo checkpoint) as its methods' this, and the level that narrows to it provides it from the parent's draft, in the same update.
  • Pairing value and actions: NarrowList (actions={appActions.todo}), useNarrowedStore(app, (a) => a.todo(id), appActions) and narrowed contexts all hand out a level's value and actions together. Identity is memoized in React, with no caches outside it.
  • Testing: called directly, a set's actions are pure functions (state in, next state out, whether they mutate or replace); createTestStore runs several in a row.
  • Also: NarrowList can memoize items with neither a setter nor a level, and the published build is now compiled with the React Compiler.

Docs: docs/actions.md. Example: examples/todomvc-actions.

Compatibility

Additive: the 0.2 exports keep their behaviour, and the existing test files are unchanged and pass. Bumps the version to 0.3.0. Needs TypeScript 5.5 or later (5.4 has a NoInfer bug); the README says so.

Also in this PR

  • Path-only writes (the change from Compose narrowed updates as one mutate recipe down the path (O(path), not O(store)) #3, cherry-picked): a write through narrowed setters is one mutate recipe on the root draft, so it costs the length of the path, not the size of the store (291 ms → 0.2 ms at 150k items). Bound actions now write the same way. A custom setter passed as a Provider's setStore must forward both arguments to a store's setter.
  • JSON patches: onPatches on useNewStore, owning Providers and createTestStore gets Mutative's patches and inverse patches for each committed update (once per update, in order, not twice in Strict Mode), with exact paths from the root. applyPatches(rootSetterOrActions, patches) writes them back and reports them as applied, for undo and sync. Patches are plain data, so any copy of Mutative can apply them.
  • The library source was reorganized for readability (no behaviour change).

Testing

  • 252 tests, run with and without the React Compiler: types, nesting, recursion, contexts, many stores from one definition, the testing patterns in src/testing.test.tsx, and a regression test for a render loop fixed in this PR.
  • A real app: zerodollarpdf's editor store (~40 actions, item gestures through a level with context, NarrowLists) ported to action sets in a scratch clone. Its own Playwright suite against the production build: 124 passed, 3 failed, identical to the unported code (the same 3 fail there, and none involve editing).
  • Patches: paths through narrowed setters, { get, swap } Maps, actions on levels and replacing actions, swaps and no-ops, undo through applyPatches, Strict Mode, batching, Providers, and patches applied by plain Mutative after a JSON round trip.
  • The packed library typechecks in a consumer project on TypeScript 5.5 through 5.9.
  • The build compiles every component and hook with the React Compiler, with no bailouts; the packed dist imports cleanly.
  • An action through a level costs the same as the equivalent setter update (about 0.6 ms at 10,000 items, dominated by the lookup).
  • examples/todomvc-actions checked in the browser.
  • New CI workflow: typecheck, tests, build, and the examples' typecheck.

🤖 Generated with Claude Code

jaaamesey and others added 5 commits October 18, 2026 20:30
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-export original, current and Draft from Mutative.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Any store can take an action set from `defineActions` instead of a setter:
`useNewStore(initial, set)`, `createContextStore(set)`, and `createTestStore`
for tests. Its actions are then the only way to write.

- Action sets narrow to other action sets, to any depth (recursive data
  included), so each part of the state only gets its own actions.
- A set declares what it needs from the levels above it as its methods'
  `this`, and the level that narrows to it provides it.
- Called directly, a set's actions are pure functions: state in, next
  state out, whether they mutate or replace.
- `NarrowList` takes a level (`actions={appActions.todo}`), or neither a
  setter nor a level to only memoize items.
- `useNarrowedStore` and `createNarrowedStoreContext` pick a level from an
  action store, handing out its value and actions together.
- The published build is compiled with the React Compiler.

Docs for actions live in docs/actions.md, and examples/todomvc-actions is
TodoMVC written with them. The 0.2 API is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The action form kept its level stable by setting state during render, so an
action set made during render (new every time) looped forever on the first
update. It now memoizes the level on the pick function, exactly as the setter
form memoizes its setter, and `sameLevel` goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Action stores: any store can take an action set from `defineActions` instead
of a setter, and its actions become the only way to write. Action sets narrow
to other action sets to any depth, get what they need from above through a
typed `this`, and are pure functions when called directly. `NarrowList`,
`useNarrowedStore` and `createNarrowedStoreContext` hand out a level's value
and actions together. See docs/actions.md.

Also:
- `NarrowList` memoizes items with neither a setter nor a level.
- `createTestStore` runs a store outside React, for tests.
- The published build is compiled with the React Compiler.
- Needs TypeScript 5.5 or later (the types use `NoInfer`).
- CI runs typecheck, tests, build and the examples' typecheck.

The 0.2 API is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jaaamesey jaaamesey changed the title Add action stores Release 0.3.0: action stores Oct 30, 2026
jaaamesey and others added 4 commits October 31, 2026 21:54
- NarrowList: split MemoListItem into ActionListItem and ReadOnlyListItem,
  rename NarrowListItem to SetterListItem, and give each its own render type
  instead of a shared `any` one
- actions.ts: name the internals for what they do (narrowLevel, actionSetOf,
  narrowTo, replaceAt, provideContext), fold the narrowing helper into the
  `narrow` given to `children`, and share Step between runAt and readLevel
- Move createTestStore to its own module
- Explain the non-obvious bits: the stable setter, the Owned components,
  the swap that bound actions use, and the lazily bound levels

No behaviour or API change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A write through nested narrowed setters used to assign each level's new
subtree into its parent's draft, so the root create() made mutative scan
the whole assigned tree: the cost of editing one item grew with the size
of the entire store (300 ms at 150k items, against 0.2 ms now).

Each narrowed setter now hands its parent a mutate recipe that resolves
its own path against the draft, so the root makes one create() and only
the path is drafted. Swaps assign just the new value at its path; a
narrowing that is the whole parent hands a replacement up to the root.
Public API and no-op behaviour (same state reference) are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FnJDYeu56rddNZDQ8Bp6YS
Stores (useNewStore, context Providers that own their store, and
createTestStore) take onPatches, which gets Mutative's patches and inverse
patches for each update once React commits it: once per update, in order,
and not twice in Strict Mode. applyPatches writes patches through a store's
root setter or root actions, and its updates are reported as applied.

Bound actions now write through the root setter as one mutate recipe, like
narrowed setters since the previous commit, so every write is one create()
on the root draft and its patches have exact paths from the root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without setStore or actions, NarrowList only wrapped each row in memo, which
the React Compiler already gives an ordinary `items.map` of a row component.
NarrowList now needs one of the two, and leaving both out is a type error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jaaamesey
jaaamesey merged commit cea0256 into main Oct 31, 2026
1 check passed

unadlib commented Nov 1, 2026

Copy link
Copy Markdown

Great work on the 0.3.0 release and the Mutative JSON Patch integration! 👏

One small suggestion: could you update the lockfile to mutative@2.0.1 (and optionally bump the dependency range to ^2.0.1)? The current package-lock.json still resolves 2.0.0.

Mutative 2.0.1 fixes an array deletion edge case where deleting a no-longer-existing index could unexpectedly grow the array, along with related sparse-array patch handling. Given the new onPatches / applyPatches APIs, it's worth picking up that fix.

Release: https://github.com/unadlib/mutative/releases/tag/v2.0.1
Fix: unadlib/mutative#213

Thanks for building on Mutative!

@jaaamesey

Copy link
Copy Markdown
Owner Author

@unadlib On it! Maybe I need to start taking this project a bit more seriously now that you've got your eyes on it 😅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants