Anchor Client Patterns with Kit
Build one real program action flow by keeping instruction building in a service layer and transport in the shared client pipeline.
The main client-side mistake with Anchor is simple.
People let React components assemble raw program calls directly.
That feels fast on day one.
It becomes a mess as soon as the app has two buttons, three account types, and one real failure path.
The correct model is:
- the service layer knows how to build program instructions
- the shared client transport knows how to send them
- the UI only gathers input, triggers the action, and renders status
That split is what keeps a Solana app explainable.
What This Lesson Owns
This lesson is not about generating an SDK.
It is about using one correctly.
Assume your Anchor program already exposes a generated instruction builder for the increment instruction.
That builder can come from your IDL generation step.
The important point is where you use it.
Step 1: Build The Program Action In One Place
Create lib/solana/programs/counterService.ts:
import { address, type Address } from "@solana/kit";
import { getIncrementInstruction } from "@/lib/generated/counter";
export type BuildIncrementCounterInput = {
counterAddress: string;
authorityAddress: string;
};
export function buildIncrementCounterAction(input: BuildIncrementCounterInput) {
const counter = address(input.counterAddress) as Address;
const authority = address(input.authorityAddress) as Address;
return {
instructions: [
getIncrementInstruction({
counter,
authority,
}),
],
};
}What this file is doing:
- converting loose UI strings into typed addresses
- calling the generated instruction builder
- returning a transport-ready instruction list
What it is not doing:
- rendering UI
- opening wallet prompts
- deciding button state
That boundary matters.
If instruction assembly lives inside the component, every future program action will duplicate logic badly.
Step 2: Send Through The Shared Client Transport
Now create components/solana/CounterActionButton.tsx:
"use client";
import React, { useState } from "react";
import { useSendTransaction, useWallet } from "@solana/react-hooks";
import { buildIncrementCounterAction } from "@/lib/solana/programs/counterService";
type CounterActionButtonProps = {
counterAddress: string;
};
export function CounterActionButton({ counterAddress }: CounterActionButtonProps) {
const wallet = useWallet();
const { send, isSending, signature, error } = useSendTransaction();
const [message, setMessage] = useState("");
const onClick = async () => {
setMessage("");
if (wallet.status !== "connected") {
setMessage("Connect a wallet before submitting a program action.");
return;
}
try {
const action = buildIncrementCounterAction({
counterAddress,
authorityAddress: wallet.session.account.address,
});
await send({ instructions: action.instructions });
setMessage("Counter increment submitted successfully.");
} catch (e: any) {
setMessage(String(e?.message ?? e));
}
};
return (
<div className="space-y-2">
<button
onClick={onClick}
disabled={wallet.status !== "connected" || isSending}
className="px-3 py-2 rounded bg-emerald-700 text-white"
>
{isSending ? "Submitting..." : "Increment Counter"}
</button>
{message ? <p className="text-xs text-zinc-300">{message}</p> : null}
{signature ? <p className="text-xs text-zinc-300">Signature: {signature}</p> : null}
{error ? <p className="text-xs text-red-300">{String(error)}</p> : null}
</div>
);
}Read the responsibility split carefully:
buildIncrementCounterAction(...)knows the program accounts and instruction shapeuseSendTransaction()knows how to push those instructions through the wallet-aware client transport- the component knows when to disable itself and what to show the user
That is the architecture you want.
Why This Pattern Scales
When the next instruction arrives, you do not rewrite the whole stack.
You add another service function.
Examples:
buildCreateProfileAction(...)buildCloseEscrowAction(...)buildClaimRewardsAction(...)
The transport stays shared.
The UI state pattern stays shared.
Only the program-specific instruction builder changes.
That is how client code stops collapsing into copy-paste logic.
The Failure Mode To Avoid
Do not do this inside a component:
- parse addresses
- build instruction accounts
- compute PDA inputs
- choose instruction builder
- send transaction
- map raw errors
That is too much responsibility for one button.
You will lose testability first.
Then you will lose correctness.
What You Can Do After This
You can keep Anchor program specifics in one service layer while still using a kit-first transport and hook-based wallet flow.
That is the bridge between on-chain program logic and a maintainable client.
The next lesson builds on that by making transaction status and recovery behavior explicit in the UI.
Quick Check
Why should the client build Anchor instructions in a service layer instead of directly inside the button component?
What should stay shared when the app adds more Anchor actions?
Up Next in Solana Kit Clients
Frontend Transaction UX: Status, Errors, Recovery
Take the shared send flow and make its phases explicit in the UI so users always know what happened and what the next safe action is.