Skip to content

feat(react): add createProgramHooks factory for Codama program plugins - #1900

Draft
amilz wants to merge 1 commit into
anza-xyz:mainfrom
amilz:feat/react-program-hooks
Draft

feat(react): add createProgramHooks factory for Codama program plugins#1900
amilz wants to merge 1 commit into
anza-xyz:mainfrom
amilz:feat/react-program-hooks

Conversation

@amilz

@amilz amilz commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

A Codama-generated program plugin is already a fully typed runtime map — codecs with fetch helpers, instruction builders with sendTransaction, PDA finders. createProgramHooks projects that map into React hooks, so one factory covers every program with no per-program codegen.

const programHooks = createProgramHooks<MyClient>();
const { useAccount, useSendInstruction } = programHooks('token');
const { usePda } = programHooks('associatedToken');

const ata = usePda('associatedToken', { mint, owner });
const balance = useAccount('token', ata.data?.[0] ?? null);
const mintTo = useSendInstruction('mintTo');

Point the type argument at the client type and an uninstalled plugin is a compile-time error. A null address, address list or seed object disables a hook rather than requiring a conditional call.

Hooks

Each one maps a single plugin function onto an existing primitive in this package, and is named for the primitive it uses.

Hook Plugin fn Primitive
useAccount .fetch useRequest
useMaybeAccount .fetchMaybe useRequest
useAllAccounts .fetchAll useRequest
useAllMaybeAccounts .fetchAllMaybe useRequest
usePda find*Pda useRequest
useTrackedAccount .decode useTrackedData
useSendInstruction builder + .sendTransaction useAction

useTrackedAccount is the only one that opens a subscription: initial getAccountInfo plus accountNotifications, slot-deduped, both decoded with the plugin's own codec. It's opt-in rather than the default for useAccount, since holding a subscription per address isn't the right cost for every app.

Notes

  • Type projections (ProgramAccountKey, ProgramInstructionInput, …) derive everything from the plugin type — no Codama naming convention is reimplemented.
  • useStableValue holds inline array/object arguments stable by value, so usePda('associatedToken', { mint, owner }) doesn't rebuild its store every render.
  • The curried call recovers the capability literal from a multi-plugin client type. The direct form createProgramHooks<{ token: TokenPlugin }>('token') still works for a single-plugin slice; on a multi-plugin type it degrades to an instructive error literal rather than silently typing against the union. Happy to simplify to curried-only if you'd prefer one call shape.

Test plan

  • 16 unit tests (createProgramHooks-test.browser.tsx) and a type test with a self-contained plugin fixture.
  • packages/react: prettier, lint, typecheck, browser unit (313), node unit (77), treeshakability — all green.

A Codama-generated program plugin is already a fully typed runtime map —
codecs with fetch helpers, instruction builders with sendTransaction, PDA
finders — so one factory covers every program with no per-program codegen.

Each hook maps one plugin function onto one existing primitive in this
package and is named for the primitive it uses: useAccount, useMaybeAccount,
useAllAccounts, useAllMaybeAccounts and usePda issue a single request via
useRequest; useTrackedAccount additionally opens a subscription via
useTrackedData, decoding both the initial getAccountInfo response and later
accountNotifications with the plugin's own codec; useSendInstruction builds,
signs and sends via useAction.

Passing a null address, address list or seed object disables a hook rather
than requiring a conditional call.
@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2b0af02

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

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

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

@@ -0,0 +1,112 @@
/**
* Type-level projections over a Codama-generated program plugin.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

is this file automatically generated each build? Curious if we truly need it committed to the repo vs having it as part of the build artifacts and NPM will deploy it even then.

const aKeys = Object.keys(aRecord);
if (aKeys.length !== Object.keys(bRecord).length) return false;
return aKeys.every(key => Object.hasOwn(bRecord, key) && isEqual(aRecord[key], bRecord[key]));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is extremely expensive to run on every render. I clanked a few options and that version seems needed but it does raise a few alarm bells for me to see deep equality checks in a hook.

Comment on lines +47 to +53
export function getAccountEntry(client: object, capability: string, key: string): ProgramAccountEntry {
const entry = getNamespace(client, capability).accounts?.[key];
if (!entry) {
throw new Error(`Program plugin "${capability}" does not declare an account named "${key}".`);
}
return entry;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'd add tsdocs strings to each function with examples for documentation and sharing intent on usage

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