Skip to content

Add ReactiveActionStore to @solana/subscribable#1550

Merged
mcintyre94 merged 2 commits into
mainfrom
action-store
May 7, 2026
Merged

Add ReactiveActionStore to @solana/subscribable#1550
mcintyre94 merged 2 commits into
mainfrom
action-store

Conversation

@mcintyre94
Copy link
Copy Markdown
Member

@mcintyre94 mcintyre94 commented Apr 21, 2026

Summary of Changes

This PR adds a new createReactiveActionStore to @solana/subscribable, which turns any async function into a state machine with subscribe/getState functionality, similar to the ReactiveStreamStore we built for subscriptions.

It's designed to be the framework-agnostic reactive-UI-friendly core for all async functions a reactive UI needs. Eg in react this would be the core of hooks like useConnectWallet and useSendTransaction.

React example:

import { createReactiveActionStore } from '@solana/subscribable';
import { useSyncExternalStore } from 'react';

const searchStore = createReactiveActionStore(async (signal, query: string) => {
    const response = await fetch(`/api/search?q=${query}`, { signal });
    return response.json() as Promise<SearchResult[]>;
});

function SearchBox() {
    const state = useSyncExternalStore(searchStore.subscribe, searchStore.getState);
    return (
        <>
            <input onChange={e => searchStore.dispatch(e.target.value)} />
            {state.status === 'running' && <Spinner />}
            {state.status === 'error' && <ErrorBanner error={state.error} />}
            {state.data && <Results items={state.data} />}
        </>
    );
}

Functionality:

  • Use dispatch to send the promise, fire-and-forget for UI handlers. The state is surfaced through the store, not the result
  • Use dispatchAsync to await the promise and handle any errors. Used for imperative handlers, eg await and then navigate.
  • Reactive state: idle, running, success, error
  • Allows calls to supersede earlier ones, by aborting the earlier signal and dropping their result. Note that this is different behaviour to eg tanstack query, and is designed for how Kit apps should behave. Clicking twice doesn't send a transaction twice.
  • stale-while-revalidate, the previous data is available if present in the running/error states
  • compatible with useSyncExternalStore and all similar reactive UI primitives

This is intended to enable a generic useAction hook in React, which other async functionality like useSendTransaction would build on. Other UI libraries would build a similar primitive. Example (simpified):

export function useAction<TArgs extends unknown[], TResult>(
    fn: (signal: AbortSignal, ...args: TArgs) => Promise<TResult>,
): UseActionResult<TArgs, TResult> {
    const [store] = useState(() =>
        createActionStore<TArgs, TResult>((signal, ...args) => fnRef.current(signal, ...args)),
    );

    const snapshot = useSyncExternalStore(store.subscribe, store.getState, store.getState);

    return useMemo(
        () => ({
            ...snapshot, // data, error, status 
            reset: store.reset,
            send: store.dispatch,
        }),
        [snapshot, store],
    )
}

Why not just use tanstack query etc?

This duplicates some of the functionality of libraries like tanstack query and SWR. The reasons are:

  • this is a framework-agnostic primitive that works with/without any UI library/caching layer
  • mentioned above, but this adds automatically aborting previous requests on double click which is the right behaviour for things like sending transactions. That's not default in tanstack etc and would require fiddly plumbing. Better to do it here and make that wrapper trivial (and optional)

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 21, 2026

🦋 Changeset detected

Latest commit: 40afbcb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 47 packages
Name Type
@solana/subscribable Minor
@solana/kit Minor
@solana/rpc-subscriptions-channel-websocket Minor
@solana/rpc-subscriptions-spec Minor
@solana/rpc-subscriptions Minor
@solana/rpc-subscriptions-api Minor
@solana/plugin-interfaces Minor
@solana/transaction-confirmation Minor
@solana/program-client-core Minor
@solana/accounts Minor
@solana/addresses Minor
@solana/assertions Minor
@solana/codecs-core Minor
@solana/codecs-data-structures Minor
@solana/codecs-numbers Minor
@solana/codecs-strings Minor
@solana/codecs Minor
@solana/compat Minor
@solana/errors Minor
@solana/fast-stable-stringify Minor
@solana/fixed-points Minor
@solana/functional Minor
@solana/instruction-plans Minor
@solana/instructions Minor
@solana/keys Minor
@solana/nominal-types Minor
@solana/offchain-messages Minor
@solana/options Minor
@solana/plugin-core Minor
@solana/programs Minor
@solana/promises Minor
@solana/react Minor
@solana/rpc-api Minor
@solana/rpc-graphql Minor
@solana/rpc-parsed-types Minor
@solana/rpc-spec-types Minor
@solana/rpc-spec Minor
@solana/rpc-transformers Minor
@solana/rpc-transport-http Minor
@solana/rpc-types Minor
@solana/rpc Minor
@solana/signers Minor
@solana/sysvars Minor
@solana/transaction-messages Minor
@solana/transactions Minor
@solana/wallet-account-signer Minor
@solana/webcrypto-ed25519-polyfill Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Copy Markdown
Member Author

mcintyre94 commented Apr 21, 2026

@bundlemon
Copy link
Copy Markdown

bundlemon Bot commented Apr 21, 2026

BundleMon

Files updated (3)
Status Path Size Limits
subscribable/dist/index.node.mjs
2.68KB (+373B +15.73%) -
subscribable/dist/index.browser.mjs
2.6KB (+356B +15.4%) -
subscribable/dist/index.native.mjs
2.61KB (+356B +15.36%) -
Unchanged files (144)
Status Path Size Limits
@solana/kit production bundle
kit/dist/index.production.min.js
51.92KB -
errors/dist/index.node.mjs
20.54KB -
errors/dist/index.browser.mjs
20.52KB -
errors/dist/index.native.mjs
20.52KB -
rpc-graphql/dist/index.browser.mjs
18.82KB -
rpc-graphql/dist/index.native.mjs
18.81KB -
rpc-graphql/dist/index.node.mjs
18.81KB -
wallet-account-signer/dist/index.node.mjs
17.38KB -
wallet-account-signer/dist/index.browser.mjs
17.37KB -
wallet-account-signer/dist/index.native.mjs
17.37KB -
transaction-messages/dist/index.browser.mjs
11.32KB -
transaction-messages/dist/index.native.mjs
11.32KB -
transaction-messages/dist/index.node.mjs
11.32KB -
instruction-plans/dist/index.browser.mjs
6.58KB -
instruction-plans/dist/index.native.mjs
6.58KB -
instruction-plans/dist/index.node.mjs
6.58KB -
fixed-points/dist/index.browser.mjs
5.08KB -
fixed-points/dist/index.native.mjs
5.07KB -
fixed-points/dist/index.node.mjs
5.07KB -
codecs-data-structures/dist/index.browser.mjs
5.04KB -
codecs-data-structures/dist/index.native.mjs
5.03KB -
codecs-data-structures/dist/index.node.mjs
5.03KB -
offchain-messages/dist/index.browser.mjs
4.89KB -
offchain-messages/dist/index.native.mjs
4.89KB -
offchain-messages/dist/index.node.mjs
4.89KB -
transactions/dist/index.browser.mjs
4.07KB -
transactions/dist/index.native.mjs
4.07KB -
transactions/dist/index.node.mjs
4.07KB -
kit/dist/index.browser.mjs
3.97KB -
kit/dist/index.native.mjs
3.97KB -
kit/dist/index.node.mjs
3.97KB -
codecs-core/dist/index.browser.mjs
3.62KB -
codecs-core/dist/index.native.mjs
3.62KB -
codecs-core/dist/index.node.mjs
3.62KB -
webcrypto-ed25519-polyfill/dist/index.node.mj
s
3.61KB -
webcrypto-ed25519-polyfill/dist/index.browser
.mjs
3.59KB -
webcrypto-ed25519-polyfill/dist/index.native.
mjs
3.57KB -
rpc-subscriptions/dist/index.browser.mjs
3.37KB -
rpc-subscriptions/dist/index.node.mjs
3.34KB -
rpc-subscriptions/dist/index.native.mjs
3.31KB -
signers/dist/index.browser.mjs
3.26KB -
signers/dist/index.native.mjs
3.26KB -
signers/dist/index.node.mjs
3.26KB -
rpc-transformers/dist/index.browser.mjs
3.16KB -
rpc-transformers/dist/index.native.mjs
3.16KB -
rpc-transformers/dist/index.node.mjs
3.16KB -
react/dist/index.browser.mjs
3.09KB -
react/dist/index.native.mjs
3.09KB -
react/dist/index.node.mjs
3.09KB -
keys/dist/index.node.mjs
3.06KB -
addresses/dist/index.browser.mjs
2.93KB -
addresses/dist/index.native.mjs
2.92KB -
addresses/dist/index.node.mjs
2.92KB -
keys/dist/index.browser.mjs
2.85KB -
keys/dist/index.native.mjs
2.85KB -
codecs-strings/dist/index.browser.mjs
2.55KB -
codecs-strings/dist/index.node.mjs
2.51KB -
codecs-strings/dist/index.native.mjs
2.47KB -
transaction-confirmation/dist/index.node.mjs
2.41KB -
sysvars/dist/index.browser.mjs
2.37KB -
sysvars/dist/index.native.mjs
2.37KB -
sysvars/dist/index.node.mjs
2.37KB -
transaction-confirmation/dist/index.native.mj
s
2.36KB -
transaction-confirmation/dist/index.browser.m
js
2.35KB -
rpc-subscriptions-spec/dist/index.node.mjs
2.25KB -
rpc-subscriptions-spec/dist/index.native.mjs
2.2KB -
rpc-subscriptions-spec/dist/index.browser.mjs
2.2KB -
rpc/dist/index.node.mjs
1.95KB -
codecs-numbers/dist/index.browser.mjs
1.95KB -
codecs-numbers/dist/index.native.mjs
1.95KB -
codecs-numbers/dist/index.node.mjs
1.94KB -
rpc-transport-http/dist/index.browser.mjs
1.91KB -
rpc-transport-http/dist/index.native.mjs
1.9KB -
rpc/dist/index.native.mjs
1.81KB -
rpc-types/dist/index.browser.mjs
1.8KB -
rpc/dist/index.browser.mjs
1.8KB -
rpc-types/dist/index.native.mjs
1.8KB -
rpc-types/dist/index.node.mjs
1.8KB -
rpc-transport-http/dist/index.node.mjs
1.72KB -
rpc-subscriptions-channel-websocket/dist/inde
x.node.mjs
1.33KB -
rpc-subscriptions-channel-websocket/dist/inde
x.native.mjs
1.27KB -
rpc-subscriptions-channel-websocket/dist/inde
x.browser.mjs
1.26KB -
program-client-core/dist/index.browser.mjs
1.21KB -
program-client-core/dist/index.native.mjs
1.21KB -
program-client-core/dist/index.node.mjs
1.21KB -
options/dist/index.browser.mjs
1.18KB -
options/dist/index.native.mjs
1.18KB -
options/dist/index.node.mjs
1.17KB -
accounts/dist/index.browser.mjs
1.17KB -
accounts/dist/index.native.mjs
1.17KB -
accounts/dist/index.node.mjs
1.16KB -
rpc-api/dist/index.browser.mjs
976B -
rpc-api/dist/index.native.mjs
975B -
rpc-api/dist/index.node.mjs
973B -
compat/dist/index.browser.mjs
969B -
compat/dist/index.native.mjs
968B -
compat/dist/index.node.mjs
966B -
rpc-spec-types/dist/index.browser.mjs
962B -
rpc-spec-types/dist/index.native.mjs
961B -
rpc-spec-types/dist/index.node.mjs
959B -
rpc-subscriptions-api/dist/index.native.mjs
870B -
rpc-subscriptions-api/dist/index.node.mjs
869B -
rpc-subscriptions-api/dist/index.browser.mjs
868B -
rpc-spec/dist/index.browser.mjs
852B -
rpc-spec/dist/index.native.mjs
851B -
rpc-spec/dist/index.node.mjs
850B -
promises/dist/index.native.mjs
841B -
promises/dist/index.node.mjs
840B -
promises/dist/index.browser.mjs
839B -
plugin-core/dist/index.browser.mjs
820B -
plugin-core/dist/index.native.mjs
819B -
plugin-core/dist/index.node.mjs
817B -
assertions/dist/index.browser.mjs
783B -
instructions/dist/index.browser.mjs
771B -
instructions/dist/index.native.mjs
770B -
instructions/dist/index.node.mjs
768B -
fast-stable-stringify/dist/index.browser.mjs
726B -
fast-stable-stringify/dist/index.native.mjs
725B -
assertions/dist/index.native.mjs
724B -
fast-stable-stringify/dist/index.node.mjs
724B -
assertions/dist/index.node.mjs
723B -
programs/dist/index.browser.mjs
329B -
programs/dist/index.native.mjs
327B -
programs/dist/index.node.mjs
325B -
fs-impl/dist/index.browser.mjs
245B -
event-target-impl/dist/index.node.mjs
230B -
functional/dist/index.browser.mjs
154B -
functional/dist/index.native.mjs
152B -
text-encoding-impl/dist/index.native.mjs
152B -
functional/dist/index.node.mjs
151B -
codecs/dist/index.browser.mjs
145B -
codecs/dist/index.native.mjs
144B -
codecs/dist/index.node.mjs
142B -
event-target-impl/dist/index.browser.mjs
133B -
ws-impl/dist/index.node.mjs
131B -
text-encoding-impl/dist/index.browser.mjs
122B -
fs-impl/dist/index.node.mjs
120B -
text-encoding-impl/dist/index.node.mjs
119B -
ws-impl/dist/index.browser.mjs
113B -
crypto-impl/dist/index.node.mjs
111B -
crypto-impl/dist/index.browser.mjs
109B -
rpc-parsed-types/dist/index.browser.mjs
66B -
rpc-parsed-types/dist/index.native.mjs
65B -
rpc-parsed-types/dist/index.node.mjs
63B -

Total files change +1.06KB +0.2%

Final result: ✅

View report in BundleMon website ➡️


Current branch size history | Target branch size history

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

Documentation Preview: https://kit-docs-9izar8b4r-anza-tech.vercel.app

@mcintyre94
Copy link
Copy Markdown
Member Author

@trevor-cortex

@mcintyre94 mcintyre94 changed the base branch from is-abort-error to graphite-base/1550 April 21, 2026 09:08
@mcintyre94 mcintyre94 changed the base branch from graphite-base/1550 to main April 21, 2026 09:09
Copy link
Copy Markdown

@trevor-cortex trevor-cortex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Adds createActionStore to @solana/subscribable: a framework-agnostic state machine (idle / running / success / error) that wraps an async function and exposes { dispatch, getState, subscribe, reset }. Each dispatch aborts the previous in-flight call via a fresh AbortController piped through getAbortablePromise, preserves the last successful data across subsequent running / error states (stale-while-revalidate), and bridges cleanly into useSyncExternalStore-style primitives. Ships with a minor changeset, a new @solana/promises dependency, a lib: ES2024.Promise tsconfig bump for Promise.withResolvers, thorough README docs, and 388 lines of tests.

What I checked

  • Supersede semantics: dispatch #2 synchronously aborts #1's controller and sets running before awaiting. On #1's late settlement, the signal.aborted guard correctly skips the stale setState. previousData is captured from state.data before the running transition, so stale-while-revalidate preserves the genuinely-last-successful value. ✓
  • Reset during running: reset() aborts, sets idle, and the still-pending dispatch awaits then sees signal.aborted → skips setState. The "superseded call resolves after reset" test covers this. ✓
  • Dependency choices: @solana/event-target-impl stays a devDep (inlined at build time per CLAUDE.md). @solana/promises is a legitimate runtime dep — getAbortablePromise is load-bearing for the AbortError rejection contract when fn doesn't respect the signal. ✓
  • Changeset: present, correctly marks @solana/subscribable as minor for a new public API. ✓
  • tsconfig bump to ES2024.Promise: required for Promise.withResolvers in the tests. No runtime impact since tsup handles compilation. ✓

Things to watch / notes for reviewers

  1. Unhandled-rejection ergonomics. dispatch always rethrows, including AbortError on supersede/reset. That's a deliberate API choice (callers can try/catch), but the intended fire-and-forget usage pattern (the searchStore.dispatch(e.target.value) example in the PR description, and anywhere dispatch is wired to onChange/onClick) will produce unhandled AbortError rejections the moment a second dispatch supersedes a first. The tests all .catch(() => {}) these, but the README React example doesn't. Worth an explicit call-out in the README's "Things to note" list, or consider swallowing AbortErrors on the returned promise (keeping them on state.error is still useful, though you'd want to distinguish abort from user error there too). Flagged inline.

  2. Broken README anchor. [isAbortError](../promises#isabortererr) won't resolve — the actual heading isAbortError(err) slugifies to #isaborterrorerr. Flagged inline.

  3. Listener re-entrancy. listeners.forEach(l => l()) with a Set handles unsubscribe-during-notify correctly, but a listener that synchronously calls dispatch / reset / subscribe will recurse. In practice reactive-UI consumers never do this (and the README's target audience is useSyncExternalStore et al.), so I'm not asking for a snapshot-the-listeners change — just noting it.

  4. currentController bookkeeping. After a dispatch settles successfully, currentController still points at the completed controller, so a later reset() will .abort() an already-aborted-or-completed signal (harmless, just slightly untidy). Not worth changing.

Overall this is a clean, well-tested addition with good API ergonomics and thoughtful docs. My two inline comments are both small.

Comment thread packages/subscribable/README.md
Comment thread packages/subscribable/src/reactive-action-store.ts
@mcintyre94 mcintyre94 changed the base branch from main to graphite-base/1550 April 21, 2026 09:56
@mcintyre94 mcintyre94 changed the base branch from graphite-base/1550 to is-abort-error April 21, 2026 09:58
@mcintyre94
Copy link
Copy Markdown
Member Author

mcintyre94 commented Apr 21, 2026

I've updated the API so there are now two functions to send the promise. I expect we would expose both in eg useAction hooks.

  • dispatch is a sync fire-and-forget that is suitable for UI event handlers. It swallows all errors, and surfaces the result only through the reactive store state
  • dispatchAsync is an async function that resolves to the result, and passes through all errors, including an abort error. This is useful for imperative event handlers, that need to eg await and then navigate.

Motivating example:

const { sendAsync, isRunning, error } = useSendTransaction();

async function onClick() {
    try {
        const result = await sendAsync(ix);
        navigate(`/tx/${result.signature}`);  // needs the awaited value
    } catch (err) {
        if (isAbortError(err)) return;
        toast.error(String(err));
    }
}

// needs store state
return <button onClick={onClick} disabled={isRunning}>{error ? 'Retry' : 'Send'}</button>;

This matches Tanstack mutations: https://tanstack.com/query/v5/docs/framework/react/guides/mutations#promises

This means that UI using dispatch behaves as expected: no errors to catch, no aborts to handle, everything is exposed through the store. An abort due to being superseded will never surface.

Advanced use cases that need to await in an event handler are also available using dispatchAsync, and can use isAbortError to avoid exposing abort errors, while still catching them.

Also stacked the branch on is-abort-error which should fix the readme link. They're tangential, but it's useful to document isAbortError as the way to filter those errors here.

@mcintyre94
Copy link
Copy Markdown
Member Author

@trevor-cortex

Copy link
Copy Markdown

@trevor-cortex trevor-cortex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review

Thanks for the fast turnaround. The dispatch / dispatchAsync split is a clean solution to the unhandled-rejection ergonomics — fire-and-forget never rejects (failures surface on state), and imperative callers get a proper promise. API shape, docs, and test coverage all look good.

Still open

  • Broken anchor in README[isAbortError](../promises#isabortererr) still resolves to a non-existent anchor. GitHub's slug for the isAbortError(err) heading is #isaborterrorerr (it includes the err param). I flagged this last round; I don't think it was picked up in the latest push.

New minor nit

  • dispatchAsync can resolve with a superseded call's value in a narrow race. If fn resolves and then the signal aborts before the await continuation runs, getAbortablePromise has already won with the value, the !signal.aborted guard correctly skips setState, but the function still returns result to the awaiting caller. That contradicts the doc claim that dispatchAsync "rejects … with an AbortError when the call is superseded". The easy fix is to check signal.aborted after the await and throw an AbortError in that branch before returning. Not worth blocking on — the window is tiny and most callers won't notice — but the docs currently overpromise. Flagged inline.

Notes for subsequent reviewers

  • The only substantive behaviour change since my last pass is the dispatch / dispatchAsync split; the core state machine, supersede semantics, and reset behaviour are unchanged and were verified last round.
  • Tests added coverage for both dispatch variants; I spot-checked the new cases and they look right.

Comment thread packages/subscribable/README.md
Comment thread packages/subscribable/src/action-store.ts Outdated
@mcintyre94 mcintyre94 force-pushed the action-store branch 2 times, most recently from c429d73 to faf218f Compare April 21, 2026 10:30
@mcintyre94
Copy link
Copy Markdown
Member Author

@trevor-cortex

Copy link
Copy Markdown

@trevor-cortex trevor-cortex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review

Both open items from the last round are resolved:

  • README anchor — now #isaborterrorerr. ✓
  • dispatchAsync supersede racesignal.aborted is now checked after both the success await and in the catch block, throwing signal.reason in either case. The catch-branch guard is a nice addition I hadn't explicitly asked for: it prevents a real fn rejection from masking the AbortError when the call is superseded between fn rejecting and the continuation running. Two new tests (resolves ... before the continuation runs, rejects ... before the continuation runs) cover both windows. ✓

The CLAUDE.md addition codifying Promise.withResolvers over hand-rolled deferreds is a nice bonus.

Nothing else regressed from my perspective — happy with this now.

@mcintyre94 mcintyre94 marked this pull request as ready for review April 21, 2026 10:35
Copy link
Copy Markdown
Member

@lorisleiva lorisleiva left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is super nice!

I like how ReactiveStore and ActionStore follow similar reactive patterns but for different purposes. One is for streaming values whereas the other allows you to dispatch new values yourself.

That being said, I think we could probably improve the way these two concepts sit next to each other. I feel like they are a lot closer than this API makes out. For instance, we should consider breaking the ActionState type into multiple getters like we do with the ReactiveStore.

ReactiveStore ActionStore (now) ActionStore (proposed)
TResult getState() getState().data getState()
error getError() getState().error getError()
status N/A [1] getState().status getStatus()

Alternatively (if we’re worried about getStatus() and getState() not being fetched automatically) we could do the opposite and make ReactiveStore return a ReactiveState.

Either way, once we unify their APIs and place ReactiveStore next to ActionStore, it becomes clearer that the main difference between the two is that ReactiveStore is readonly whereas ActionStore is a dispatchable version of ReactiveStore. Therefore, we could make that association more obvious by renaming ActionStore to DispatchableReactiveStore.

What do you think?

[1]: Side note, we could even envisage adding a getStatus() function to ReactiveStores for API symmetry even though the the running status wouldn’t be applicable here.

Comment thread CLAUDE.md
- **Dev-only code**: Guard with `__DEV__` (e.g. verbose error messages, debug assertions).
- **Formatting**: ESLint via `@solana/eslint-config-solana`, Prettier via `@solana/prettier-config-solana`. Run `pnpm style:fix` to auto-fix.
- **All publishable packages share a fixed version** (currently in lockstep).
- **Deferred promises**: Use `Promise.withResolvers<T>()` instead of hand-rolling a `new Promise((resolve, reject) => ...)` with captured externals. Do not reintroduce a `deferred()` helper — `Promise.withResolvers` already returns `{ promise, resolve, reject }`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

Base automatically changed from is-abort-error to main April 21, 2026 12:41
@mcintyre94
Copy link
Copy Markdown
Member Author

@lorisleiva I like the idea of the two APIs being closer together, but I don't think doing it that way would work as well as it appears.

For reactive-store the error case is fatal, rather than being part of the lifecycle. The intended use is (currently) something like:

// ErrorBoundary somewhere above
const data = useSyncExternalStore(store.subscribe, () => {
  if (store.getError()) throw store.getError();
  return store.getState();
});

Here getState() is returning the latest data, and if there's an error somewhere then that's the end of the stream and we throw it as an error. It's designed to mirror the async generator in that way.

Whereas with action-store the intended use case is that you dispatch a function, and it could throw, but that's handled by the store. An error because the user rejected the transaction is recoverable by dispatching again, and the store handles a retried response superseding the previous one.

I also think it's important that we have getState() return all the data, so that useSyncExternalStore(store.subscribe, store.getState) gives you a cohesive type. Otherwise you'd need a separate useSyncExternalStore for each field that you want to track, and you wouldn't have the narrowing provided by ActionState - if status === 'success' then data is defined and error is undefined etc. I also don't think the react (etc) lifecycle would work as well if each field was a separate useSyncExternalStore because we only have one subscribe, not one per field.

We could expose getData etc functions that just do getStore().data, but they'd be strictly weaker typed. And given the expectation is that consumers are using store.subscribe (through useSyncExternalStore or otherwise), I don't think they'd be useful in most cases.

I think that the action-store design is generally a bit better than reactive-store, because of the more cohesive getState type (with status) and the retry-ability.

We likely can't make createReactiveStoreFromDataPublisher retry-able as-is, because it's taking a DataPublisher as input. We can't restart that if there's an error and it's dead, unless we change it to receive a factory function that creates a DataPublisher, then it could recreate it. Maybe that's worth doing - we could just deprecate the current version that doesn't use a factory to avoid the breaking change. Then we could make ReactiveStore include a retry or restart function. Noop if running, recreate if there was an error.

This would also work for createReactiveStoreWithInitialValueAndSlotTracking as-is, since that takes pending RPC/subscription requests as input and could restart them. This would make it more similar to action-store in that way - it still tears down the subscription on error but knows how to rebuild it if you want to. This would make the store returned by that function more similar to this action-store.

There is also an argument to make getState() return something more like the discriminated union state type used here, which would be a breaking change. But at that point the error case becomes more like action-store: a recoverable part of the lifecycle that you might just want to flag in the UI. Maybe we add that as getUnifiedState() or something inevitably annoying, and then in a breaking change we can make getState() change to that.

This would essentially lead us to a similar outcome of the stores having a more similar shape, but from the opposite direction - bringing the more flexible API here into reactive-store. The state machines are much more similar, and the getState snapshot state {data, error, status} is the same (status values probably different though).

TLDR: I think there's a stronger case for unifying by bringing the unified state type and retry functionality into reactive-store

@mcintyre94 mcintyre94 changed the base branch from graphite-base/1550 to rename-reactive-store April 22, 2026 13:41
@mcintyre94 mcintyre94 changed the title Add createActionStore to @solana/subscribable Add ReactiveActionStore to @solana/subscribable Apr 22, 2026
@mcintyre94
Copy link
Copy Markdown
Member Author

@trevor-cortex

Copy link
Copy Markdown

@trevor-cortex trevor-cortex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (rename round)

The Reactive-prefix rename is mechanical and clean — ActionStoreReactiveActionStore, createActionStorecreateReactiveActionStore, file/test renamed accordingly, README and barrel updated. Implementation is byte-for-byte the same as last round modulo identifier names; the supersede / reset / dispatchAsync race fixes from prior rounds are preserved. No new code-level concerns.

On Loris's API-unification proposal

For subsequent reviewers — my two cents on Loris's comment, since it's a design call rather than a correctness one:

The two APIs are already heading toward the same shape, just under different names. ReactiveStreamStore already exposes getUnifiedState() returning a single discriminated ReactiveState<T> snapshot, and the per-field getState() / getError() getters on it are explicitly @deprecated in favour of that unified getter (see the README and JSDoc on reactive-stream-store.ts). So the modern, recommended ReactiveStreamStore API is already a single-snapshot getter, exactly like what ReactiveActionStore ships in this PR.

Given that, I'd lean against the first option in Loris's table (splitting ReactiveActionState back into getStatus() / getError() / etc.) — that would walk away from the direction Stream is already headed and re-introduce the snapshot-identity / useSyncExternalStore footgun that getUnifiedState() was added to fix.

The friction Loris is pointing at feels more like naming inconsistency than shape inconsistency:

Concept Stream (today) Action (this PR)
Discriminated snapshot getter getUnifiedState() getState()
Snapshot type ReactiveState<T> ReactiveActionState<TResult>

IMO the cleanest unification is to align names, not shapes. Two reasonable directions:

  1. Action adopts Stream's vocabulary — rename to getUnifiedState() here too, and call the snapshot type ReactiveActionState<…> (already does). Pro: zero churn on the Stream side, which already has deprecation cycles in flight. Con: getUnifiedState is a slightly awkward name when there's nothing to unify against on the action side.
  2. Stream adopts Action's vocabulary — promote getState() to be the unified getter on Stream (the deprecated value-only getState() is in the way, but it's already deprecated, so a future major can reclaim the name). Pro: getState() reads more naturally and matches useSyncExternalStore docs. Con: adds churn to the Stream deprecation plan.

I'd weakly prefer (2) long-term but (1) is the lower-risk move for this PR if you want to land it without dragging Stream into scope.

On the DispatchableReactiveStore rename — I'd push back gently. "ActionStore" is widely-recognised vocabulary (Redux/Flux/Zustand lineage) and ReactiveActionStore already telegraphs the relationship via the shared Reactive*Store prefix. DispatchableReactiveStore is more architecturally precise but loses the discoverability of "action" as a concept. The shared prefix arguably already does the symmetry work the rename is trying to do.

Not blocking either way — flagging this for discussion before the next push so Loris and Callum can land on a direction without a third rename round.

Notes for subsequent reviewers

  • Behaviour and tests are unchanged from the previous approval-shaped pass; no need to re-verify the supersede / reset / dispatchAsync race semantics.
  • The only outstanding question is the API-shape one above; once that's settled the code itself is in good shape.

@mcintyre94 mcintyre94 marked this pull request as ready for review April 22, 2026 14:00
@mcintyre94 mcintyre94 force-pushed the rename-reactive-store branch from 4319058 to b1fcac1 Compare April 22, 2026 14:00
Copy link
Copy Markdown
Member

@lorisleiva lorisleiva left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the changes!

Copy link
Copy Markdown
Member Author

mcintyre94 commented May 7, 2026

Merge activity

  • May 7, 2:56 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • May 7, 3:22 PM UTC: Graphite rebased this pull request as part of a merge.
  • May 7, 3:32 PM UTC: @mcintyre94 merged this pull request with Graphite.

mcintyre94 added a commit that referenced this pull request May 7, 2026
#### Problem

`ReactiveStore` exposed `getState()` and `getError()` as separate getters with no unified snapshot, which made `useSyncExternalStore` integration awkward. Errors were also terminal — once a stream failed, the only recovery was to rebuild the whole store, losing subscribers and last-known state in the process.

#### Summary of Changes

This PR brings `ReactiveStore` closer to the proposed `ActionStore`: #1550. See discussion: #1550 (review) 

- `ReactiveStore<T>` gains `retry(): void` and `getUnifiedState(): ReactiveState<T>`. `ReactiveState<T>` is a discriminated union over `loading | loaded | error | retrying` with stable snapshot identity across reads. The existing `getState()` and `getError()` getters are preserved but marked `@deprecated`. A future breaking change can make `getState()` the new `getUnifiedState`
- New `createReactiveStoreFromDataPublisherFactory` accepts a `() => Promise<DataPublisher>` factory and supports real retry, wiring a fresh inner abort signal for each connection attempt.
- The existing `createReactiveStoreFromDataPublisher` is now `@deprecated`. Its `retry()` throws a new `SolanaError` with code `SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED` since a ready-made `DataPublisher` can't be restarted.
- `createReactiveStoreWithInitialValueAndSlotTracking` (in `@solana/kit`) now implements `retry()` by re-invoking the pending RPC request and subscription on a new inner abort signal, preserving `lastUpdateSlot` and the last known value.
- Updated tests for both factories and the slot-tracking store; updated the subscribable README.
@mcintyre94 mcintyre94 changed the base branch from rename-reactive-store to graphite-base/1550 May 7, 2026 15:10
mcintyre94 added a commit that referenced this pull request May 7, 2026
#### See discussion: #1550 (comment)

We decided to rename `ReactiveStore` to `ReactiveStreamStore`, to make space for an orthogonal `ReactiveActionStore`.
@mcintyre94 mcintyre94 changed the base branch from graphite-base/1550 to main May 7, 2026 15:20
mcintyre94 added 2 commits May 7, 2026 15:21
Adds `createActionStore` to `@solana/subscribable`: a framework-agnostic state machine that turns any async function into a `{ dispatch, getState, subscribe, reset }` store. The snapshot is a discriminated `ActionState<TResult>` keyed on `status: 'idle' | 'running' | 'success' | 'error'`, so the store always has a defined snapshot and consumers never need to handle `undefined`.

Each `dispatch` creates a fresh `AbortController` and aborts any in-flight predecessor. The superseded call's outcome is dropped unconditionally — stale resolutions and stale failures can never overwrite the newer call's state. The wrapped function receives the `AbortSignal` as its first argument so it can cooperatively cancel network or compute work. This is intended to become the core of a `useAction` React hook but works equally well against Svelte stores, Vue's `shallowRef`, or a vanilla consumer.

Chosen as a sibling of `ReactiveStore<T>` rather than a subtype — the contracts diverge on whether the snapshot is always defined and on how errors surface — and the README calls out the distinction. `dispose()` was intentionally omitted: `reset()` already aborts in-flight work and listeners are garbage-collected when the store is dropped, so a separate disposer would be scope for a future change rather than part of the initial surface.
@mcintyre94 mcintyre94 merged commit 82a1ac5 into main May 7, 2026
14 checks passed
@mcintyre94 mcintyre94 deleted the action-store branch May 7, 2026 15:32
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

🔎💬 Inkeep AI search and chat service is syncing content for source 'Solana Kit Docs'

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