Skip to content

Add useSubscription for rendering based on a subscription#1702

Open
mcintyre94 wants to merge 1 commit into
rpc-types/unwrap-rpc-responsefrom
react/use-subscription
Open

Add useSubscription for rendering based on a subscription#1702
mcintyre94 wants to merge 1 commit into
rpc-types/unwrap-rpc-responsefrom
react/use-subscription

Conversation

@mcintyre94
Copy link
Copy Markdown
Member

@mcintyre94 mcintyre94 commented May 26, 2026

Summary of Changes

This PR adds the useSubscription hook:

function AccountBalance({ address }: { address: Address }) {
    const client = useClient<ClientWithRpcSubscriptions<AccountNotificationsApi>>();
    const source = useMemo(() => client.rpcSubscriptions.accountNotifications(address), [client, address]);
    const { data, slot, error, reconnect } = useSubscription(source);
     if (error) return <button onClick={reconnect}>Reconnect</button>;
    return <p>{data ? `${data.lamports} lamports at slot ${slot}` : 'Connecting…'}</p>;
}

This is a subscription analog to useRequest. It uses the reactiveStreamStore state machine, and immediately initialises the subscription on mount.

The expected input is a ReactiveStreamSource, the current implementation in Kit is a pending RPC subscriptions object. But eg. plugins could also publish their own ReactiveStreamSource of the shape reactiveStore(): ReactiveStreamStore and that would also work with this.

If a subscription publishes messages with shape SolanaRpcResponse<T> then the slot is automatically unpacked using splitSolanaRpcResponse, and data is typed as T. If the subscription does not have this shape then slot is typed as undefined, and data is the unchanged notification.

As with useRequest the hook takes an optional getAbortSignal which creates an abort signal per connect (initial and calls to reconnect). The call to reconnect can optionally override this per call.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 26, 2026

🦋 Changeset detected

Latest commit: af665ca

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

This PR includes changesets to release 47 packages
Name Type
@solana/react 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/kit Minor
@solana/nominal-types Minor
@solana/offchain-messages Minor
@solana/options Minor
@solana/plugin-core Minor
@solana/plugin-interfaces Minor
@solana/program-client-core Minor
@solana/programs Minor
@solana/promises 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-subscriptions-api Minor
@solana/rpc-subscriptions-channel-websocket Minor
@solana/rpc-subscriptions-spec Minor
@solana/rpc-subscriptions Minor
@solana/rpc-transformers Minor
@solana/rpc-transport-http Minor
@solana/rpc-types Minor
@solana/rpc Minor
@solana/signers Minor
@solana/subscribable Minor
@solana/sysvars Minor
@solana/transaction-confirmation 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 May 26, 2026

@bundlemon
Copy link
Copy Markdown

bundlemon Bot commented May 26, 2026

BundleMon

Files updated (25)
Status Path Size Limits
react/dist/index.browser.mjs
4.61KB (+1.52KB +49.1%) -
react/dist/index.native.mjs
4.61KB (+1.52KB +49.08%) -
react/dist/index.node.mjs
4.61KB (+1.52KB +49.1%) -
subscribable/dist/index.node.mjs
2.82KB (+144B +5.25%) -
subscribable/dist/index.browser.mjs
2.74KB (+142B +5.32%) -
subscribable/dist/index.native.mjs
2.75KB (+142B +5.31%) -
rpc-types/dist/index.browser.mjs
1.92KB (+126B +6.83%) -
rpc-types/dist/index.native.mjs
1.92KB (+126B +6.84%) -
rpc-types/dist/index.node.mjs
1.92KB (+125B +6.79%) -
wallet-account-signer/dist/index.browser.mjs
17.57KB (+45B +0.25%) -
wallet-account-signer/dist/index.native.mjs
17.57KB (+45B +0.25%) -
wallet-account-signer/dist/index.node.mjs
17.59KB (+44B +0.24%) -
errors/dist/index.browser.mjs
20.76KB (+36B +0.17%) -
errors/dist/index.native.mjs
20.76KB (+35B +0.16%) -
errors/dist/index.node.mjs
20.78KB (+35B +0.16%) -
rpc-subscriptions-spec/dist/index.browser.mjs
2.19KB (-13B -0.58%) -
rpc-subscriptions-spec/dist/index.native.mjs
2.19KB (-13B -0.58%) -
rpc-subscriptions-spec/dist/index.node.mjs
2.23KB (-13B -0.56%) -
rpc-spec/dist/index.browser.mjs
898B (-20B -2.18%) -
rpc-spec/dist/index.native.mjs
897B (-21B -2.29%) -
rpc-spec/dist/index.node.mjs
896B (-21B -2.29%) -
@solana/kit production bundle
kit/dist/index.production.min.js
52.49KB (-129B -0.24%) -
kit/dist/index.node.mjs
4.13KB (-352B -7.69%) -
kit/dist/index.browser.mjs
4.13KB (-353B -7.71%) -
kit/dist/index.native.mjs
4.13KB (-353B -7.71%) -
Unchanged files (122)
Status Path Size Limits
rpc-graphql/dist/index.browser.mjs
18.82KB -
rpc-graphql/dist/index.native.mjs
18.81KB -
rpc-graphql/dist/index.node.mjs
18.81KB -
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 -
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 -
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.42KB -
transaction-confirmation/dist/index.native.mj
s
2.37KB -
sysvars/dist/index.browser.mjs
2.37KB -
sysvars/dist/index.native.mjs
2.37KB -
transaction-confirmation/dist/index.browser.m
js
2.37KB -
sysvars/dist/index.node.mjs
2.37KB -
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/dist/index.browser.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
998B -
rpc-api/dist/index.native.mjs
997B -
rpc-api/dist/index.node.mjs
995B -
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
871B -
rpc-subscriptions-api/dist/index.browser.mjs
870B -
rpc-subscriptions-api/dist/index.node.mjs
870B -
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 +4.32KB +0.82%

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 May 26, 2026

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

@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.

Adds a useSubscription hook that is a near-perfect mirror of useRequest for ReactiveStreamSources, plus a disabledStreamStore companion for the null-source case. Composition is clean: useSubscription owns lifecycle (memo store, effect connect+reset, ref-synced getAbortSignal, stable reconnect), useSubscriptionResult does the useSyncExternalStore bridge and the SolanaRpcResponse unwrap via splitSolanaRpcResponse. The pattern matches useRequest / useRequestResult so closely that someone who already understands one can read the other immediately — which is exactly the right outcome here.

Things to watch out for:

  • index.ts export ordering is broken. useSubscription is inserted between useSignAndSendTransaction and useSignIn, which puts it out of alphabetical order with the useSign* cluster. Should sit after useSignTransaction instead. Trivial fix; flagging inline.
  • SSR snapshot getter passed to useSyncExternalStore. useSubscriptionResult passes store.getUnifiedState as both the client and server snapshot getter — that's what makes the SSR test pass without React warning about a missing server snapshot, and it's only correct because IDLE_STATE / IDLE_STREAM_STATE are frozen singletons with stable identity. Worth noting that useRequestResult doesn't currently pass a server snapshot at all, so there's a small inconsistency between the two bridges — not a blocker for this PR, but probably worth a follow-up to bring useRequestResult in line (or document why it doesn't need it).
  • slot after loadederror. The docblock for SubscriptionResult.slot lists when it's undefined (loading / disabled / server / error-only). After a successful loaded that transitions to error, the stale envelope is preserved in state.data and splitSolanaRpcResponse will keep returning the stale slot. That's the right SWR behaviour, and the docs aren't wrong, but the "when only an error has arrived" qualifier is doing more work than it looks — readers may assume error always nukes slot. Optional: a short note that slot follows data (stale on error after a prior loaded).

Notes for subsequent reviewers:

  • The fake subscription helper in useSubscription-test.browser.tsx relies on the store binding both the data and error channels via .on() on every connect() — it counts to 2 before resolving publisherReady. If createReactiveStoreFromDataPublisherFactory's binding order or channel count ever changes, that handshake silently deadlocks. Worth a peek if you're touching the underlying store.
  • The useEffect([store]) cleanup ordering matters under StrictMode and source identity changes: cleanup runs store.reset() on the previous store closure, then the new store's connect() fires. The fake's listener-array reset inside createDataPublisher is what makes the "aborts the prior connection's listeners when the source identity changes" test pass cleanly — confirmed by reading through; just calling out where the invariant lives in case it's not obvious.
  • The disabledStreamStore tests are thorough (frozen state, no-op connect/reset/retry/withSignal, no-notify subscribe). Coverage of the disabled path is solid.
  • Typetest covers the unwrap on both envelope and raw shapes, the null-source case, the presence-based reconnect API, and asserts the status union doesn't leak the action-store 'success' vocabulary. Good signal-to-noise.

Comment thread packages/react/src/index.ts
Comment thread packages/react/src/useSubscription.ts
reconnect: (options?: { abortSignal?: AbortSignal | undefined }) => void,
disabled: boolean,
): SubscriptionResult<UnwrapRpcResponse<T>> {
const state = useSyncExternalStore(store.subscribe, store.getUnifiedState, store.getUnifiedState);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Worth noting that this passes store.getUnifiedState as the server snapshot (3rd arg) — useRequestResult doesn't. The reason this is correct here is that idle state is a frozen singleton with stable identity, so the server and client snapshots match on first paint and useSyncExternalStore doesn't warn. Probably worth a follow-up to bring useRequestResult in line (its current 2-arg form likely triggers a React SSR warning in dev). Not a blocker for this PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated useAction and useRequest to use the same pattern in their PRs

`useSubscription(source)` opens a stream-store subscription on mount, re-opens whenever the source identity changes, and tears it down on unmount. The source is any `ReactiveStreamSource<T>` — `PendingRpcSubscriptionsRequest` is the canonical implementation. Pass `null` to disable.

The hook mirrors `useRequest`'s structure exactly: construct the lazy store via `useMemo`, fire `store.connect()` in a `useEffect`, tear down via `store.reset()` in cleanup. Same StrictMode-safe lifecycle pattern (mount → cleanup aborts → mount re-fires), same vocabulary, same per-call signal API.

The hook returns `{ data, error, reconnect, slot, status }`. Status is one of `loading | loaded | error | disabled`. After a notification arrives, an error-channel publish transitions to `error` while preserving the stale `data`; `reconnect()` returns to `loading` (preserving stale `data` and `error` for stale-while-revalidate) before settling on `loaded` or a fresh `error`. The bridge maps the store's `idle` state to `loading` when enabled (matching the about-to-commit-effect render) and to `disabled` when the source is null.

Notifications shaped as `SolanaRpcResponse<U>` (account/program/signature) are unwrapped via `splitSolanaRpcResponse` from `@solana/rpc-types`: `data` is the inner value `U` and `slot` is lifted from `context.slot`. Raw notifications (slot/logs/root) pass through with `slot: undefined`. The `UnwrapRpcResponse<T>` conditional type (also from `@solana/rpc-types`) tracks the runtime unwrap at the type level.

Optional `getAbortSignal: () => AbortSignal` is invoked on every connection (initial subscribe + every `reconnect()`). The returned signal is composed with the store's per-connection controller via `AbortSignal.any` through `withSignal(signal).connect()`. The natural use is per-connection timeouts (`() => AbortSignal.timeout(30_000)`), which reset on reconnect. Factory is ref-synced — inline closures are fine. `reconnect()` accepts an optional `{ abortSignal }` override for one specific attempt; presence-based semantics distinguish "use factory" (omit the key), "explicit signal" (`{ abortSignal: signal }`), and "no signal" (`{ abortSignal: undefined }`).

SSR-safe: on the server the connect effect doesn't run, so the store stays `idle` and the hook reports `status: 'loading'`. The first client render hydrates from the same paint and commits the connect.

Adds `disabledStreamStore<T>()` to `staticStores.ts` — the stream-store analogue of `disabledActionStore` for the null-source case. Pinned by new tests alongside the existing `disabledActionStore` invariants.

Exports `SubscriptionResult<T>` and `UseSubscriptionOptions` for plugin hooks to build on.
@mcintyre94 mcintyre94 force-pushed the react/use-subscription branch from 72a4b00 to af665ca Compare May 27, 2026 12:00
@mcintyre94 mcintyre94 marked this pull request as ready for review May 27, 2026 12:14
@mcintyre94 mcintyre94 requested a review from lorisleiva May 27, 2026 12:14
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.

2 participants