Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/flat-ants-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solana/kit-react': minor
---

Add `@solana/kit-react` — React bindings for Kit. A single `KitClientProvider` distributes a caller-built Kit client to the subtree; plugin composition happens in plain Kit via `createClient().use(...)` so any Kit plugin works without a React-specific wrapper. The provider accepts `Client | Promise<Client>`, suspending via the nearest `<Suspense>` boundary when given a promise (`React.use(promise)` on 19, a thrown-promise shim on 18). Ships live-data hooks (`useBalance`, `useAccount`, `useTransactionConfirmation`), generic data hooks (`useLiveData`, `useSubscription`, `useRequest`), action hooks (`useAction`, `useSendTransaction(s)`, `usePlanTransaction(s)`), signer hooks (`usePayer`, `useIdentity`), and the `useClient` / `useChain` / `useClientCapability` escape hatches.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ node_modules
# misc
.DS_Store
*.pem
dist

# debug
npm-debug.log*
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This is a monorepo managed with [pnpm](https://pnpm.io/) and [Turborepo](https:/
| [`@solana/kit-plugin-litesvm`](./packages/kit-plugin-litesvm) | LiteSVM support plugin. |
| [`@solana/kit-plugin-instruction-plan`](./packages/kit-plugin-instruction-plan) | Transaction planning and execution plugins. |
| [`@solana/kit-plugin-wallet`](./packages/kit-plugin-wallet) | Browser wallet support plugins. |
| [`@solana/kit-react`](./packages/kit-react) | React bindings for Solana Kit — providers and hooks. |

The umbrella package (`@solana/kit-plugins`) is deprecated. It re-exports everything from the individual plugin packages via `export *` statements for backward compatibility, but consumers should import from the individual `kit-plugin-*` packages directly.

Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,33 @@ This repo provides the following individual plugin packages. You can learn more
| [`@solana/kit-plugin-instruction-plan`](./packages/kit-plugin-instruction-plan) | [![npm](https://img.shields.io/npm/v/@solana/kit-plugin-instruction-plan.svg?style=flat)](https://www.npmjs.com/package/@solana/kit-plugin-instruction-plan) | Transaction planning and execution | `transactionPlanner`, `transactionPlanExecutor`, `planAndSendTransactions` |
| [`@solana/kit-plugin-wallet`](./packages/kit-plugin-wallet) | [![npm](https://img.shields.io/npm/v/@solana/kit-plugin-wallet.svg?style=flat)](https://www.npmjs.com/package/@solana/kit-plugin-wallet) | Browser wallet support | `walletSigner`, `walletIdentity`, `walletPayer`, `walletWithoutSigner` |

## React Bindings

For React applications, [`@solana/kit-react`](./packages/kit-react) provides a thin layer over Kit — providers for composing a client and hooks like `useBalance`, `useAccount`, `useSendTransaction`, `useSubscription`, and `useRequest`. See the [package README](./packages/kit-react/README.md) for the full surface.

```sh
pnpm install @solana/kit @solana/kit-react @solana/kit-plugin-rpc
```

```tsx
import { KitClientProvider, SolanaMainnetRpcProvider, useBalance } from '@solana/kit-react';

function BalanceCard({ owner }) {
const { data, status } = useBalance(owner);
return status === 'loading' ? <Spinner /> : <Lamports value={data} />;
}

export function App() {
return (
<KitClientProvider>
<SolanaMainnetRpcProvider rpcUrl="https://api.mainnet-beta.solana.com">
<BalanceCard owner={myAddress} />
</SolanaMainnetRpcProvider>
</KitClientProvider>
);
}
```

## Community Plugins

| Package | Description | Plugins | Maintainers |
Expand Down
4 changes: 4 additions & 0 deletions packages/kit-react/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dist/
test-ledger/
target/
CHANGELOG.md
458 changes: 458 additions & 0 deletions packages/kit-react/README.md

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions packages/kit-react/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{
"name": "@solana/kit-react",
"version": "0.1.0",
"description": "React bindings for Kit: a client-first provider plus live on-chain data, RPC, and transaction hooks",
"exports": {
"types": "./dist/types/index.d.ts",
"react-native": "./dist/index.react-native.mjs",
"browser": {
"import": "./dist/index.browser.mjs",
"require": "./dist/index.browser.cjs"
},
"node": {
"import": "./dist/index.node.mjs",
"require": "./dist/index.node.cjs"
}
},
"browser": {
"./dist/index.node.cjs": "./dist/index.browser.cjs",
"./dist/index.node.mjs": "./dist/index.browser.mjs"
},
"main": "./dist/index.node.cjs",
"module": "./dist/index.node.mjs",
"react-native": "./dist/index.react-native.mjs",
"types": "./dist/types/index.d.ts",
"type": "commonjs",
"files": [
"./dist/types",
"./dist/index.*",
"./src/"
],
"sideEffects": false,
"keywords": [
"solana",
"kit",
"react",
"hooks"
],
"scripts": {
"build": "rimraf dist && tsup && tsc -p ./tsconfig.declarations.json",
"dev": "vitest --project node",
"lint": "eslint . && prettier --check .",
"lint:fix": "eslint --fix . && prettier --write .",
"test": "pnpm test:types && pnpm test:unit",
"test:types": "tsc --noEmit",
"test:unit": "vitest run"
},
"peerDependencies": {
"@solana/kit": "^6.8.0",
"react": "^18.0.0 || ^19.0.0"
},
"dependencies": {
"@solana/subscribable": "^6.8.0"
},
"devDependencies": {
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/anza-xyz/kit-plugins"
},
"bugs": {
"url": "https://github.com/anza-xyz/kit-plugins/issues"
},
"browserslist": [
"supports bigint and not dead",
"maintained node versions"
]
}
67 changes: 67 additions & 0 deletions packages/kit-react/src/client-capability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Client } from '@solana/kit';

import { useClient } from './client-context';
import { throwMissingCapability } from './errors';

/**
* Options for {@link useClientCapability}.
*/
export type UseClientCapabilityOptions = Readonly<{
/**
* The capability name, or an ordered list of capability names, that
* must be present on the client. Each is checked via `key in client`.
*/
capability: string | readonly string[];
/** Hook name shown as the subject of the error. */
hookName: string;
/** Free-text "how to fix" hint appended to the error. */
providerHint: string;
}>;

/**
* Reads the Kit client from context and asserts that the listed capability
* (or capabilities) are installed.
*
* The TypeScript narrowing is caller-declared: the hook does not infer
* `TClient` from the capability names. Mismatched casts are caller errors,
* same as with {@link useClient}. Hook authors who want a loud
* missing-provider failure at mount should prefer this over
* `useClient<T>()`.
*
* @typeParam TClient - The client shape the caller expects after narrowing.
* @param options - The capability name(s), hook name, and provider hint.
* @return The Kit client cast to `Client<TClient>`.
* @throws An `Error` describing the missing capability when any listed key
* is not present on the client.
*
* @example
* ```ts
* import type { ClientWithRpc, GetEpochInfoApi } from '@solana/kit';
*
* function useEpochInfo() {
* const client = useClientCapability<ClientWithRpc<GetEpochInfoApi>>({
* capability: 'rpc',
* hookName: 'useEpochInfo',
* providerHint: 'Install `solanaRpc()` or `solanaRpcConnection()` on the client.',
* });
* return useRequest(() => client.rpc.getEpochInfo(), [client]);
* }
* ```
*
* @see {@link useClient}
*/
export function useClientCapability<TClient extends object>(options: UseClientCapabilityOptions): Client<TClient> {
const client = useClient<TClient>();
const keys: readonly string[] = Array.isArray(options.capability)
? options.capability
: [options.capability as string];
for (const key of keys) {
if (!(key in (client as object))) {
const formatKey = (k: string): string => '`client.' + k + '`';
const description: string =
keys.length === 1 ? formatKey(key) : 'all of ' + keys.map((k: string) => formatKey(k)).join(', ');
throwMissingCapability(options.hookName, description, options.providerHint);
}
}
return client;
}
177 changes: 177 additions & 0 deletions packages/kit-react/src/client-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type { Client } from '@solana/kit';
import { createContext, type ReactNode, useContext } from 'react';

import { usePromise } from './internal/use-promise';

/**
* Known Solana chain identifiers following the wallet-standard / CAIP-2
* `namespace:reference` convention. Prefer one of these literals so that
* TypeScript autocompletes the correct value at call sites.
*/
export type SolanaChain = 'solana:mainnet' | 'solana:devnet' | 'solana:testnet';

/**
* Wallet-standard / CAIP-2 namespaced chain identifier
* (`namespace:reference`, e.g. `'solana:mainnet'`, `'eip155:1'`). Inlined
* here so this package doesn't need a runtime dep on
* `@wallet-standard/base` for a type-only import.
*/
type ChainIdentifierString = `${string}:${string}`;

/**
* Chain identifier accepted by {@link KitClientProvider}. Solana literals
* autocomplete; the intersection with `ChainIdentifierString & {}`
* preserves those literals while still accepting any `${string}:${string}`
* identifier for custom or non-Solana chains.
*/
export type ChainIdentifier = SolanaChain | (ChainIdentifierString & {});

/**
* React context carrying the Kit client.
*
* Exposed so advanced consumers can compose on top of an existing context.
* Most callers should reach for {@link useClient} or a named hook instead.
*
* @see {@link useClient}
*/
export const ClientContext = createContext<Client<object> | null>(null);
ClientContext.displayName = 'KitClientContext';

/**
* React context carrying the chain identifier configured on the nearest
* {@link KitClientProvider}.
*
* @see {@link useChain}
*/
export const ChainContext = createContext<ChainIdentifier | null>(null);
ChainContext.displayName = 'KitChainContext';

/**
* Props for {@link KitClientProvider}.
*/
export type KitClientProviderProps = Readonly<{
/**
* A Kit client, or a promise resolving to one. When a promise is
* passed, the provider suspends via the nearest `<Suspense>` boundary
* until it resolves (React 19's `use(promise)` on 19, a thrown-promise
* shim on 18). The promise must be stable across renders — wrap it in
* `useMemo` or hoist it to module scope.
*/
client: Client<object> | Promise<Client<object>>;
/**
* Optional chain identifier to publish to the subtree. Wallet-aware
* descendants read it via {@link useChain}.
*/
chain?: ChainIdentifier;
children?: ReactNode;
}>;

/**
* Root provider for `@solana/kit-react`.
*
* Publishes a caller-owned Kit client to the subtree. Every hook in this
* library requires a {@link KitClientProvider} ancestor. The client is
* built outside React via `createClient().use(...)`; pass the result (or a
* `Promise` of one when any installed plugin is async) as the `client`
* prop. The provider itself does no composition, lifecycle management, or
* disposal — that all belongs to the caller.
*
* When `client` is a promise, the provider suspends until it resolves, so
* a `<Suspense>` boundary must be mounted above.
*
* @param props - The Kit client (sync or async), optional chain, and children.
* @return A React element that publishes the Kit client to its subtree.
*
* @example
* ```tsx
* import { createClient } from '@solana/kit';
* import { solanaMainnetRpc } from '@solana/kit-plugin-rpc';
* import { walletSigner } from '@solana/kit-plugin-wallet';
* import { KitClientProvider } from '@solana/kit-react';
*
* const client = createClient()
* .use(walletSigner({ chain: 'solana:mainnet' }))
* .use(solanaMainnetRpc({ rpcUrl: 'https://api.mainnet-beta.solana.com' }));
*
* export function App() {
* return (
* <KitClientProvider client={client} chain="solana:mainnet">
* <Shell />
* </KitClientProvider>
* );
* }
* ```
*
* @see {@link useClient}
* @see {@link useChain}
*/
export function KitClientProvider({ chain, children, client }: KitClientProviderProps) {
const resolved = isPromiseLike(client) ? usePromise(client) : client;
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> {
Comment on lines +110 to +114
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.

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

// duck-typing the `.then` method as a fallback. A plain Kit client is an
// object built via `extendClient`, which never installs `then`, so this
// is unambiguous in practice.
return typeof (value as { then?: unknown }).then === 'function';
}

/**
* Reads the Kit client from context.
*
* Defaults to the base `Client<object>` shape. Callers who know specific
* plugins are installed can widen via the generic argument, matching the
* escape-hatch pattern used by {@link useChain}.
*
* @typeParam TClient - The client shape to widen the return to. Defaults to `object`.
* @return The Kit client cast to `Client<TClient>`.
* @throws An `Error` if no {@link KitClientProvider} ancestor is present.
*
* @example
* ```tsx
* const client = useClient<ClientWithRpc<SolanaRpcApi>>();
* const epoch = await client.rpc.getEpochInfo().send();
* ```
*
* @see {@link useClientCapability}
* @see {@link useChain}
*/
export function useClient<TClient extends object = object>(): Client<TClient> {
const client = useContext(ClientContext);
if (client === null) {
throw new Error('useClient() must be called inside a <KitClientProvider>.');
}
return client as Client<TClient>;
}

/**
* Reads the chain identifier from context.
*
* The chain is published by the nearest {@link KitClientProvider} via its
* `chain` prop. Wallet-aware code reaches for this; plain read paths that
* don't care about cluster can ignore it.
*
* @typeParam T - Widen the return type when your provider is configured
* with a custom chain identifier. Defaults to the narrow
* {@link SolanaChain} union so callers get autocomplete.
* @return The chain identifier published by {@link KitClientProvider}.
* @throws An `Error` if no chain has been published.
*
* @example
* ```tsx
* const chain = useChain();
* // chain is 'solana:mainnet' | 'solana:devnet' | 'solana:testnet'
* ```
*/
export function useChain<T extends ChainIdentifierString = SolanaChain>(): T {
const chain = useContext(ChainContext);
if (chain === null) {
throw new Error('useChain() requires a chain to be set via the `chain` prop on <KitClientProvider>.');
}
return chain as T;
}
17 changes: 17 additions & 0 deletions packages/kit-react/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Throws a consistently-formatted error describing a missing client
* capability.
*
* Used by {@link useClientCapability} and the hook-local assertions in
* {@link usePayer}, {@link useIdentity}, etc.
*
* @param hookName - Name of the hook raising the error, used as the subject.
* @param capabilityDescription - Human-readable description of the missing capability.
* @param providerHint - Free-text "how to fix" hint appended to the error.
* @throws Always throws an `Error` with the formatted message.
*
* @internal
*/
export function throwMissingCapability(hookName: string, capabilityDescription: string, providerHint: string): never {
throw new Error(`${hookName}() requires ${capabilityDescription} on the Kit client. ${providerHint}`);
}
Loading
Loading