Skip to content

Add @solana/kit-react core package#205

Closed
mcintyre94 wants to merge 10 commits into
subscribe-to-reactive-capabilityfrom
kit-react-core
Closed

Add @solana/kit-react core package#205
mcintyre94 wants to merge 10 commits into
subscribe-to-reactive-capabilityfrom
kit-react-core

Conversation

@mcintyre94
Copy link
Copy Markdown
Member

@mcintyre94 mcintyre94 commented Apr 24, 2026

This PR adds react bindings for Kit Plugins, building on the reactive types added to Kit. This is basically useSyncExternalStore over Kit's reactive types, in React-friendly wrappers.

Note that it depends on open Kit PRs as well as the stack of kit-plugin PRs below it. In order to build and test this, we have a temporary kit-prereqs directory with shims for these. These will disappear when Kit PRs merge. I'll keep this PR in draft until then, and reviewers can ignore kit-prereqs. This PR description will describe Kit with all those PRs merged for clarity.

This PR only adds the core of kit-react. Functionality for the wallet plugin, and adapters for cache libraries, are not included yet. The PR includes the spec, which also describes these.

Note that this probably belongs in Kit, not kit-plugins. It's useful to have the draft PR here, and then we can stack a PR adding a react subpath to the wallet plugin, to validate the API. Everything here probably lands in Kit though.

The real goal here is that the React bindings are genuinely thin - state machines and fiddly logic are implemented in Kit, this just wraps them in React APIs. The library is also client-first: consumers build the Kit client themselves with createClient().use(...) and hand it to a single React provider. Plugin composition belongs in Kit, not in the React tree.

We have a single React provider:

  • KitClientProvider is the only provider. It accepts a client prop (or Promise<Client> for async plugins) and an optional chain prop, and publishes both to the subtree. The provider does no composition, lifecycle management, or disposal — that all belongs to the caller. When client is a promise, the provider suspends via the nearest <Suspense> boundary; on React 19 this uses native React.use(promise), on React 18 a small thrown-promise shim provides the same contract.

We have a few hooks related to the provider: useClient<T> to get the client, useClientCapability to do mount-time checks for required capabilities (with a typed missing-plugin error), useChain to get the chain if configured.

Between client-first composition and useClientCapability, apps can use arbitrary plugins without requiring specific react bindings to be published for them. Plugins can optionally ship them as a value add — the convention is a /react subpath on the plugin package, peer-depending on @solana/kit-react (or wherever this lands) for useClientCapability + useAction etc. A follow-up PR adding the wallet plugin's react functionality will demonstrate this, and will be the validation suite for this API before kit-react eventually moves into the Kit monorepo.

The other hooks are wrappers around Kit's react primitives.

Kit now has ReactiveActionStore which provides a reactive state for any async function, and ReactiveStreamStore which provides a reactive state for any async subscription.

kit-react provides four basic hooks based on these stores (examples simplified for brevity):

  • useAction((signal) => signer.signMessage("cool!", { abortSignal: signal })): user-triggered actions backed by ReactiveActionStore
  • useRequest(() => client.rpc.getEpochInfo()): async requests dispatched on mount, backed by ReactiveActionStore. The factory requires a .reactiveStore(): ReactiveActionStore function, which is being added to Kit's RPC methods.
  • useSubscription(() => client.rpcSubscriptions.slotNotifications()) : subscriptions backed by ReactiveStreamStore. Similarly, just requires a .reactiveStore(): ReactiveStreamStore function, provided by Kit RPC subscription methods.
  • useLiveData : Subscriptions that combine an RPC request with an RPC subscription and always provide the latest data from either source. Backed by ReactiveStreamStore, using Kit's createReactiveStoreWithInitialValueAndSlotTracking

While we intentionally do not provide eg a specific named hook for every RPC request (they'd be one-liners around useRequest), we do provide some higher-level hooks built on these primitives + useClientCapability.

useBalance(address), useAccount(address, decoder?) and useTransactionConfirmation all combine a specific RPC and subscription in useLiveData to provide functionality commonly required by apps.

useSendTransaction(s) and usePlanTransaction(s) provide useAction wrappers around the transaction planning/sending plugins. Plugin-specific functions would generally look very similar to this, with the logic belonging to the plugin and not to react.

usePayer and useIdentity read client.payer / client.identity reactively via the subscribeTo<Capability> convention - they're capability-agnostic, so wallet plugins, static signer plugins, and any future reactive signer plugin all participate without kit-react naming specific plugin types.

Notes:

  • For now this is part of kit-plugins. The plan is to move kit-react into the Kit monorepo eventually (as a separate published package - React stays out of @solana/kit's dep tree). I want to validate the API by building the wallet plugin's React bindings against it first, so the move is sequenced after that.
  • Async plugins are supported out of the box - createClient().use(asyncPlugin) returns Promise<Client>, hand it to KitClientProvider, mount a <Suspense> above. No special async mode in kit-react itself.
  • Like the wallet plugin PRs, this includes the spec file - for transparency and to explain the upcoming work. This will be removed from the repo later.
  • For now I'm using kit-react as the package name. I think we should probably land this in @solana/react in the Kit monorepo. We'd deprecate the existing functions there, which are superseded by the combination of this and the wallet plugin react bindings.

First drop of `@solana/kit-react`, a thin `useSyncExternalStore` layer over Kit. Ships providers for composing a client, named live-data hooks (`useBalance`, `useAccount`, `useTransactionConfirmation`), generic data hooks (`useLiveData`, `useSubscription`, `useRequest`), transaction and action hooks, and signer readers — all typed against the plugins installed on the client.

Chain is optional and orthogonal to the client: chain-specific RPC providers publish it implicitly, or apps can mount `<ChainProvider>` directly. `useChain()` throws if nothing has published one, which surfaces misconfiguration at mount time instead of at runtime.

Includes the `react-lib-spec.md` RFC at repo root for review; it'll be removed once the design is settled. A `src/kit-prereqs/` shim module stands in for unreleased Kit primitives (`ReactiveStreamStore`, `ReactiveActionStore`, `.reactiveStore()` on pending requests) and gets deleted in a follow-up once Kit ships them. Out of scope: `/wallet` subpath, SWR/TanStack adapters, async-plugin support.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 24, 2026

🦋 Changeset detected

Latest commit: 5ea85af

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

This PR includes changesets to release 1 package
Name Type
@solana/kit-react 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

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

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

Summary

Adds the new @solana/kit-react package: a thin React layer over Kit that wires providers (root client, chain, plugin, RPC connection, full RPC, LiteSVM, payer/identity/signer) and hooks (useClient / useChain / useClientCapability, reactive primitives useAction / useRequest / useSubscription / useLiveData, and the higher-level useBalance / useAccount / useTransactionConfirmation / useSendTransaction(s) / usePlanTransaction(s) / usePayer / useIdentity). State machines live in Kit; the package glues them to useSyncExternalStore and handles SSR, disabled (null) queries, abort-on-supersede, and slot-based dedup.

Shape is clean and consistent — every live hook returns the same LiveQueryResult envelope, every action hook the same ActionResult, and the null-gate disabled story is wired through both stream and action paths via sentinel symbols. Nice. Docblocks, README, and changeset are all present and coherent.

One important note for reviewers: the PR intentionally ships against unmerged Kit PRs (kit#1550/#1552/#1553/#1555) via the src/kit-prereqs/ shim folder. That folder is clearly documented and has a deletion plan tied to each upstream PR — fine for a draft, but worth double-checking on the day you flip over that the upstream signatures match what kit-react consumes (particularly PendingRpcSubscriptionsRequest.reactiveStore({ abortSignal }) which the sync shim never actually re-implements — see below).

Things worth a second look

  1. LiteSvmProvider memo dependency list is incomplete — only config.transactionConfig is listed, so other LiteSvmConfig fields (sans transactionConfig) won't rebuild the plugin when they change. The sibling RPC providers enumerate every field. Inline comment below.
  2. useSubscription / useRequest don't wire the shim bridges — both call pending.reactiveStore(...) directly, but kit-prereqs exports reactiveStoreFromPendingRequest / reactiveStoreFromPendingSubscriptionsRequest without them being referenced anywhere in src/. Today, neither hook works against current-Kit pending objects (the .reactiveStore method lands with kit#1553 / kit#1555). That matches "depends on open Kit PRs" in the description, but the shims themselves are effectively dead code — worth either wiring them up as a bridge until the Kit PRs merge, or deleting them from kit-prereqs/index.ts so it's clear useSubscription/useRequest don't have a fallback path today.
  3. useSyncExternalStore ordering in usePayer / useIdentity — the hook subscribes first and checks 'payer' in client second. It works (the throw happens unconditionally after all hook calls, so hook order is stable), but it's a bit surprising on first read. Inline comment.
  4. useAccount's decoder param is part of useLiveData deps — if consumers pass an inline decoder literal (useAccount(addr, getMintDecoder())), they'll churn the store on every render. The dev-mode identity-churn warning only fires on PluginProvider's plugins prop; might be worth either extending the warning into the live-data hooks or calling this out explicitly in the useAccount README snippet. Not a blocker.

Notes for subsequent reviewers

  • Kit prereqs: the implementations in src/kit-prereqs/ are essentially redeclarations of the upstream PR contracts; worth cross-referencing against each linked Kit PR (particularly the ReactiveStreamStore shape — getState + getError + getUnifiedState coexisting is deliberate per the spec but unusual).
  • SSR path: IS_LIVE_CLIENT = __BROWSER__ || __REACTNATIVE__. Node builds (including the vitest node project) get the permanently-loading fallback — that's why a number of tests are gated on __BROWSER__ || __REACTNATIVE__. Confirm the vitest configs for browser / RN exercise the live paths.
  • Abort semantics: createReactiveActionStore aborts the previous controller on every fresh dispatch, and useAction threads that into the user's fn(signal, ...args). Worth double-checking the useSendTransaction / usePlanTransaction adapters actually pass the signal through — they do ({ ...config, abortSignal: signal }), but note that if a caller passes their own abortSignal in config, it gets overridden silently. That's documented behaviour per the spec; just flagging.
  • Test coverage: providers, capability checks, null-gates, live emissions, and action supersede are all covered. I didn't see a test for usePayer / useIdentity returning null when the client's getter throws (readOptional's try/catch path); worth adding if wallet plugins that throw on disconnected state are a real scenario.
  • Bundle shape: the package.json exports map, browser field, and browserslist look right for a Node/browser/RN triple-bundle. The Prettier ignore, tsup config, and vitest project config all match the sibling plugin packages.

Comment thread packages/kit-react/src/providers/litesvm-provider.tsx Outdated
Comment thread packages/kit-react/src/hooks/signers.ts Outdated
Comment thread packages/kit-react/src/kit-prereqs/index.ts
Comment thread packages/kit-react/src/hooks/use-account.ts
Comment thread packages/kit-react/src/providers/rpc-provider.tsx Outdated
@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

Looks good — the substantive points from my prior pass are addressed:

  • use-request / use-subscription now wire the kit-prereqs shims, with a typed union (ReactiveActionSource / ReactiveStreamSource) that accepts both a native .reactiveStore() and the current PendingRpc(Subscriptions)Request shape, falling back to reactiveStoreFromPending(...). Both branches carry a TODO(kit#...) for the eventual collapse. Clean bridge — this was the biggest gap last time and it's the right fix.
  • useAccount now calls useIdentityChurnWarning(decoder, ...) with a good remedy string pointing at module-level memoization. Exactly what I wanted.
  • readOptional throwing-getter path has test coverage in both usePayer and useIdentity, including full null → value → null cycles for the reactive-subscribe case. Nice.
  • Four RPC providers now share useRpcConfigPlugin, eliminating the dep-list duplication — addresses the refactor suggestion I made last time.

Retracting one thing from my prior review

I flagged LiteSvmProvider's memo dep list ([config.transactionConfig]) as incomplete. That was wrong — LiteSvmConfig today only has transactionConfig, so the dep list is exhaustive. Apologies for the noise. Same drift hazard as the shared helper note below if it ever grows fields, but nothing to change now.

One small note for maintainers, not blocking

useRpcConfigPlugin closes over config via build and has an eslint-disable for exhaustive-deps, relying on the explicit seven-field key list to track churn. That's fine today, but if a new field ever lands on SolanaRpcConfig it'll need to be added here too or the provider will silently stop rebuilding on that field. A short comment on the key list calling that out — or a type-level satisfies Record<keyof SolanaRpcConfig<string>, unknown> guard on a sibling object keyed by the same fields — would make future drift loud rather than silent. Same structural hazard applies to LiteSvmProvider if its config grows.

No blockers from me. The remaining shape (Kit prereqs, draft status, async plugins follow-up) is as described in the PR body — would approve if I had the permission to.

@mcintyre94 mcintyre94 requested a review from lorisleiva April 24, 2026 21:31
@mcintyre94
Copy link
Copy Markdown
Member Author

@trevor-cortex Please review based on the updated design

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

Major redesign — and a real improvement. The provider stack collapses from many specialized providers (KitClient/Plugin/Chain/Rpc{,Connection}/LiteSvm/Signer/Payer/Identity) down to a single KitClientProvider, with client composition pushed entirely into Kit-land via createClient().use(...). Async plugins are now handled via Promise<Client> + <Suspense> rather than a separate sync-only mode. The signer hooks lean on a subscribeTo<Capability> duck-type convention so wallet plugins, static signer plugins, and any future reactive signer plugin all participate without kit-react naming specific plugin types. Net: fewer concepts to learn, escape hatches are uniform, and the bindings stay genuinely thin.

A usePromise indirection (React 19's native React.use with a thrown-promise shim fallback for React 18) cleanly hides the Suspense wire protocol from KitClientProvider. Hook bodies are unchanged from the prior pass — useAction / useRequest / useSubscription / useLiveData and the higher-level named hooks still wrap Kit's ReactiveActionStore / ReactiveStreamStore with the same LiveQueryResult / ActionResult envelopes.

Prior-review follow-ups — addressed

  • ✅ The shim bridges (reactiveStoreFromPendingRequest / reactiveStoreFromPendingSubscriptionsRequest) are now wired in use-request.ts and use-subscription.ts, each with a TODO(kit#…) comment marking the eventual collapse.
  • useAccount fires useIdentityChurnWarning on the decoder arg with a remedy that points at module-scope memoization. README also calls it out explicitly.
  • usePayer / useIdentity capability check now precedes the useSyncExternalStore call (not the prior surprising order), and both have full coverage including the throwing-getter path and a null → value → null disconnect/reconnect cycle.
  • ✅ Per-provider memo dep-list duplication concern is now moot — the providers it applied to (RpcProvider, RpcConnectionProvider, LiteSvmProvider, etc.) are gone. Composition lives in Kit.

Things worth a second look

  1. Suspense resolution-and-retry is unverified end-to-end. client-context.test.tsx correctly notes that happy-dom + vitest don't flush React 19's Suspense scheduler after a settled promise — even native React.use(promise) reproduces the hang. So we have: (a) a unit-tested shim contract (pending throws / fulfilled returns / rejected re-throws), (b) a unit-tested suspend-half (throws → fallback renders), and (c) React's own contract for the retry. The composition isn't exercised in CI. That's acceptable, but it means a regression at the React-version boundary (e.g. a stray useMemo slipping into KitClientProvider that breaks the React 18 fallback path) wouldn't be caught. Worth either a minimal jsdom-based integration test using a controlled promise + manual act(async () => { await promise; }) flush, or — at minimum — a CI matrix run against React 18 to exercise the shim path. The current test suite is React 19 only as far as I can see.

  2. client prop identity stability has no dev-mode safety net. The README is clear that the client prop must be stable across renders (module-scope or useMemo). For decoder in useAccount, you wired useIdentityChurnWarning to flag inline-allocation footguns. The same footgun exists at the root: <KitClientProvider client={createClient().use(...)}> inline in a component will silently churn every render — no warning, the children just keep remounting. Worth firing the same churn warning on the resolved client inside KitClientProvider. Inline comment below.

  3. useChain() is unconditionally throwy. Reasonable default — if you call it, you presumably need it. But for a read-only dashboard that never touches a chain, a hook deep in some shared component might call useChain() for telemetry / display and explode. The error message is clear, but consider exposing a useOptionalChain(): ChainIdentifier | null variant to make the "display if available" pattern explicit without try/catch. Not blocking — just an ergonomic gap.

  4. isPromiseLike duck-types on .then. Fine in practice — Kit clients are built via extendClient and never install a .then. The realm-crossing fallback comment is on point. One edge: if a consumer wraps a client in a Proxy that intercepts .then (extremely unlikely, but real for some debugging proxies), the runtime check would misidentify it as a promise. Worth the instanceof check first (which it already is) — the duck-type fallback is the only path that could trip, and only across realms. Note for posterity, not action.

Notes for subsequent reviewers

  • Conditional usePromise call in KitClientProvider. Looks like a Rules of Hooks violation at first glance (isPromiseLike(client) ? usePromise(client) : client), but it isn't. usePromise is a thin indirection over React.use (which is explicitly conditional-call-safe in React 19) and the shim that only throws — neither tracks per-component state. Calling it conditionally is fine. That said, if a caller swaps the client prop's type between Client and Promise<Client> at runtime, the user-facing behavior (suspends mid-tree, then resolves) is correct but worth confirming with a manual test.
  • shimUsePromise cache lifecycle. The WeakMap<Promise, State> is keyed on promise identity. Stale entries can't accumulate — when the promise is GC'd, the entry goes with it. The unit tests cover pending / fulfilled / rejected / multiple promises. Solid.
  • useClientCapability capability iteration. 'rpc' in client checks both own and inherited keys. Plugin authors who use extendClient should be fine since extensions are own-properties; if someone hand-rolls a client via prototype chain it'd still work. Mention only.
  • Test helper Providers. Each <Providers extensions={...}> mount builds a fresh client via useMemo([extensions]). Tests that pass an inline extensions object literal will get a fresh client every render — fine for usePayer static tests, but worth keeping in mind if you ever add a test that asserts client identity stability across re-renders.
  • README. Comprehensive, accurate, and the disposal section is a particularly good addition — explicit about when Symbol.dispose matters (per-request SSR clients, dynamic rebuild) versus when it doesn't (long-running SPAs).
  • Bundle / package shape. exports map is React 18+19 compatible, browser/node/RN triple-bundle is preserved, peerDependencies correctly pins both React majors. @solana/subscribable as a runtime dep is new vs the prior pass — check that it's actually pulled in by something the bundle ships (didn't see it referenced in the diffs I read; might be an indirect transitive).

I'd APPROVE if I had the permission for it on this repo — submitting as COMMENT instead. The two real points worth tracking are the Suspense integration coverage gap (#1) and the missing churn warning on client (#2); everything else is discussion or future ergonomics.

function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
if (value instanceof Promise) return true;
// Handle realm-crossing promises (a `Promise` instantiated in a different
// global, e.g. an iframe or a test runner that swaps globalThis) by
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Consider firing useIdentityChurnWarning on client (and possibly chain) here — same rationale as the decoder warning in useAccount. The README is clear that client must be stable across renders, but inline <KitClientProvider client={createClient().use(...)}> will silently churn every render today with no dev-time signal. The footgun is at the root rather than at a leaf, so a user who hits it will see remount-storms across their entire tree.

Something like:

useIdentityChurnWarning(client, {
    argName: 'client',
    hookName: 'KitClientProvider',
    remedy: 'Build the client at module scope or wrap it in `useMemo`. The provider does not own client lifecycle.',
});

Skip when client is a promise (different identity contract — the promise itself must be stable, the resolved value is whatever it resolves to).

Comment on lines +110 to +114
const tree = <ClientContext.Provider value={resolved}>{children}</ClientContext.Provider>;
return chain ? <ChainContext.Provider value={chain}>{tree}</ChainContext.Provider> : tree;
}

function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: tree allocates a new <ClientContext.Provider> element on every render even when neither resolved nor children changed. Context bails on equal value, so the perf cost is bounded to a createElement call per render — but if you ever wanted to add the same JSX pattern in a hot path, this is the kind of place to reach for useMemo. Not worth changing here; just flagging the pattern.

Comment on lines +27 to +45
export function shimUsePromise<T>(promise: Promise<T>): T {
let state = cache.get(promise) as PromiseState<T> | undefined;
if (!state) {
state = { status: 'pending' };
cache.set(promise, state as PromiseState<unknown>);
promise.then(
value => {
cache.set(promise, { status: 'fulfilled', value });
},
reason => {
cache.set(promise, { reason, status: 'rejected' });
},
);
}
// Throwing the promise itself is Suspense's wire protocol — React attaches
// its own `.then` to retry render once the promise settles. Not an Error.
// eslint-disable-next-line @typescript-eslint/only-throw-error
if (state.status === 'pending') throw promise;
if (state.status === 'rejected') throw state.reason;
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 a brief note in the docblock that the shim is only safe to invoke from inside a render that's wrapped by a <Suspense> boundary — without one, the thrown promise propagates to the nearest error boundary (or crashes the tree). The KitClientProvider JSDoc says this for the user, but shimUsePromise is exported for tests, and a future internal caller that grabs it without reading the surrounding context would get an unfriendly failure mode.

remedy: 'Memoize the decoder at the module level (e.g. `const MINT_DECODER = getMintDecoder()`) or wrap in `useMemo`.',
});
return useLiveData(
() => (address ? createAccountLiveData(client, address, decoder) : null),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: the decoder is in the dep list, which is correct, but the rebuild it triggers cascades all the way down to createReactiveStoreWithInitialValueAndSlotTracking — meaning a churning decoder doesn't just rebuild the React-side store, it tears down the live RPC subscription. That's the right semantic (different decoder = different mapped value type), but combined with the useIdentityChurnWarning it's worth one extra sentence in the warning's remedy: "…otherwise the underlying account subscription will be torn down and re-opened on every render." Makes the cost concrete.

Comment on lines +75 to +81
describe('usePromise (auto-detected React.use vs shim)', () => {
it('exposes a function regardless of React version', () => {
// Sanity: the runtime detection picks one of two callables. The
// test environment runs React 19 so this is `React.use`; on 18 it
// would be `shimUsePromise`. Either way it is callable.
expect(typeof usePromise).toBe('function');
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: this test only asserts that usePromise is callable, which is essentially a typeof check on a module export. Consider either dropping it or strengthening it — e.g. assert that on React 19 it's referentially equal to React.use, and on React 18 it's shimUsePromise. That'd actually catch a regression in the runtime detection (e.g. a stray bundler shim that returns undefined for React.use).

@mcintyre94
Copy link
Copy Markdown
Member Author

Closing - this is mostly moving to Kit @solana/react, only plugin-specific functionality will be in kit-plugins

@mcintyre94 mcintyre94 closed this May 7, 2026
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