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

@mcintyre94 mcintyre94 commented May 26, 2026

Copy link
Copy Markdown
Member

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, error, reconnect } = useSubscription(source);
     if (error) return <button onClick={reconnect}>Reconnect</button>;
    return <p>{data ? `${data.value.lamports} lamports at slot ${data.context.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.

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

changeset-bot Bot commented May 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 25f6fbb

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

mcintyre94 commented May 26, 2026

Copy link
Copy Markdown
Member Author

@bundlemon

bundlemon Bot commented May 26, 2026

Copy link
Copy Markdown

BundleMon

Files updated (25)
Status Path Size Limits
react/dist/index.browser.mjs
5.06KB (+1.96KB +63.43%) -
react/dist/index.native.mjs
5.06KB (+1.96KB +63.42%) -
react/dist/index.node.mjs
5.05KB (+1.96KB +63.44%) -
@solana/kit production bundle
kit/dist/index.production.min.js
52.84KB (+243B +0.45%) -
errors/dist/index.browser.mjs
20.9KB (+179B +0.84%) -
errors/dist/index.native.mjs
20.9KB (+179B +0.84%) -
errors/dist/index.node.mjs
20.92KB (+179B +0.84%) -
wallet-account-signer/dist/index.browser.mjs
17.69KB (+169B +0.94%) -
wallet-account-signer/dist/index.native.mjs
17.69KB (+168B +0.94%) -
wallet-account-signer/dist/index.node.mjs
17.71KB (+168B +0.93%) -
kit/dist/index.native.mjs
4.63KB (+161B +3.52%) -
kit/dist/index.node.mjs
4.63KB (+161B +3.52%) -
kit/dist/index.browser.mjs
4.63KB (+160B +3.49%) -
subscribable/dist/index.browser.mjs
2.76KB (+160B +6%) -
subscribable/dist/index.native.mjs
2.77KB (+160B +5.99%) -
subscribable/dist/index.node.mjs
2.83KB (+158B +5.76%) -
rpc-types/dist/index.browser.mjs
1.9KB (+102B +5.53%) -
rpc-types/dist/index.native.mjs
1.9KB (+101B +5.48%) -
rpc-types/dist/index.node.mjs
1.9KB (+101B +5.48%) -
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%) -
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.89KB -
rpc-transport-http/dist/index.native.mjs
1.89KB -
rpc/dist/index.native.mjs
1.81KB -
rpc/dist/index.browser.mjs
1.8KB -
rpc-transport-http/dist/index.node.mjs
1.71KB -
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 +8.28KB +1.57%

Final result: ✅

View report in BundleMon website ➡️


Current branch size history | Target branch size history

@github-actions

github-actions Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

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

@mcintyre94

Copy link
Copy Markdown
Member Author

@trevor-cortex

@trevor-cortex trevor-cortex left a comment

Copy link
Copy Markdown

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
Comment on lines +36 to +31
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

@mcintyre94 mcintyre94 force-pushed the react/use-subscription branch from cc6b909 to 72a4b00 Compare May 27, 2026 11:47
@mcintyre94 mcintyre94 force-pushed the rpc-types/unwrap-rpc-response branch from 8dcf3dd to 3aa3b8a Compare May 27, 2026 11:47
@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
@mcintyre94 mcintyre94 force-pushed the rpc-types/unwrap-rpc-response branch from 3aa3b8a to ac893f1 Compare May 28, 2026 10:03
@mcintyre94 mcintyre94 force-pushed the react/use-subscription branch 3 times, most recently from 624e92f to 9ea41a8 Compare May 28, 2026 10:13
@mcintyre94 mcintyre94 force-pushed the rpc-types/unwrap-rpc-response branch from ac893f1 to 2606839 Compare May 28, 2026 10:13
@mcintyre94 mcintyre94 marked this pull request as draft May 28, 2026 10:50
@mcintyre94 mcintyre94 force-pushed the react/use-subscription branch 2 times, most recently from 67b86de to b384ab6 Compare May 28, 2026 11:50
@mcintyre94 mcintyre94 marked this pull request as ready for review May 28, 2026 12:01

@lorisleiva lorisleiva left a comment

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.

LGTM!

Comment thread packages/react/src/__typetests__/useSubscription-typetest.ts Outdated
@mcintyre94 mcintyre94 force-pushed the rpc-types/unwrap-rpc-response branch from 2606839 to bb281ff Compare May 29, 2026 10:36
@mcintyre94 mcintyre94 force-pushed the react/use-subscription branch from b384ab6 to 91c17e6 Compare May 29, 2026 10:36
@mcintyre94 mcintyre94 force-pushed the rpc-types/unwrap-rpc-response branch from bb281ff to 611f542 Compare June 3, 2026 15:17
@mcintyre94 mcintyre94 force-pushed the react/use-subscription branch from 91c17e6 to 086ac26 Compare June 3, 2026 15:17
@mcintyre94 mcintyre94 force-pushed the rpc-types/unwrap-rpc-response branch from 611f542 to 9fefb8f Compare June 5, 2026 15:39
@mcintyre94 mcintyre94 force-pushed the react/use-subscription branch 2 times, most recently from 973fcc8 to 2d3ae0e Compare June 5, 2026 15:47
@mcintyre94 mcintyre94 force-pushed the rpc-types/unwrap-rpc-response branch from 9fefb8f to 02d93ac Compare June 5, 2026 15:47
@mcintyre94 mcintyre94 force-pushed the react/use-subscription branch from 2d3ae0e to 8bd304c Compare June 5, 2026 15:56
@mcintyre94 mcintyre94 force-pushed the rpc-types/unwrap-rpc-response branch 2 times, most recently from 5b68fd4 to b9d4e6b Compare June 10, 2026 15:43
@mcintyre94 mcintyre94 force-pushed the react/use-subscription branch from 8bd304c to 3b21574 Compare June 10, 2026 15:43
@mcintyre94 mcintyre94 force-pushed the rpc-types/unwrap-rpc-response branch from b9d4e6b to 4db12b0 Compare June 10, 2026 16:49
@mcintyre94 mcintyre94 force-pushed the react/use-subscription branch from 3b21574 to 613df77 Compare June 10, 2026 16:49
@mcintyre94 mcintyre94 added the do-not-close Add this tag to exempt an issue/PR from being closed by the stalebot label Jun 11, 2026
`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 `isSolanaRpcResponse` 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do-not-close Add this tag to exempt an issue/PR from being closed by the stalebot

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants