RPC Reads and Account Decoding with Kit
Fetch accounts safely with @solana/kit, normalize the result for UI code, and decode account data only after you know what you fetched.
Reading Solana state looks simple until the client starts guessing.
A string that is not a real address gets passed through the UI.
A missing account is treated like a crash.
Raw bytes get decoded before you even know which program owns them.
That is how read paths become brittle.
The correct model is:
- fetch first
- check existence
- inspect ownership and shape
- decode only when you know what you are looking at
That order matters.
What A Read Path Actually Returns
At the RPC layer, an account is just facts:
- address
- lamports
- owner
- executable flag
- raw data bytes
Those facts are useful on their own.
Decoding is a second step.
Do not merge them too early.
Step 1: Fetch One Account In A Unified Shape
The Kit docs recommend fetchEncodedAccount(...) when you want one consistent account shape instead of juggling raw RPC return variants.
Create lib/solana/rpc.ts:
import {
address,
assertAccountExists,
createSolanaRpc,
fetchEncodedAccount,
type Address,
} from "@solana/kit";
import { SOLANA_RPC } from "@/lib/solana/config";
const rpc = createSolanaRpc(SOLANA_RPC);
export type AccountView =
| { exists: false; address: string }
| {
exists: true;
address: string;
lamports: string;
owner: string;
executable: boolean;
dataLength: number;
};
export async function fetchAccountView(input: string): Promise<AccountView> {
const accountAddress = address(input.trim()) as Address;
const account = await fetchEncodedAccount(rpc, accountAddress);
if (!account.exists) {
return {
exists: false,
address: input.trim(),
};
}
return {
exists: true,
address: account.address,
lamports: account.lamports.toString(),
owner: account.owner,
executable: account.executable,
dataLength: account.data.length,
};
}
export async function fetchExistingEncodedAccount(input: string) {
const accountAddress = address(input.trim()) as Address;
const account = await fetchEncodedAccount(rpc, accountAddress);
assertAccountExists(account);
return account;
}This file teaches the main split:
fetchAccountView(...)is for UI-safe inspectionfetchExistingEncodedAccount(...)is for flows where missing data should be treated as a real error
That is already a much stronger model than returning raw RPC blobs everywhere.
Why exists Matters
Kit returns a MaybeEncodedAccount shape on purpose.
That is not ceremony.
A missing account is normal on Solana.
Examples:
- wrong cluster
- address copied incorrectly
- account not created yet
- PDA derived correctly but not initialized yet
That should not explode your UI by default.
Step 2: Show The Read Result Without Guessing
Now create components/solana/AccountInspector.tsx:
"use client";
import React, { useState } from "react";
import { fetchAccountView, type AccountView } from "@/lib/solana/rpc";
export function AccountInspector() {
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<AccountView | null>(null);
const [error, setError] = useState("");
const onFetch = async () => {
setLoading(true);
setError("");
try {
setResult(await fetchAccountView(input));
} catch (e: any) {
setResult(null);
setError(String(e?.message ?? e));
} finally {
setLoading(false);
}
};
return (
<div className="space-y-3">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full px-3 py-2 rounded bg-zinc-900"
placeholder="Account address"
/>
<button
onClick={onFetch}
disabled={loading}
className="px-3 py-2 rounded bg-cyan-600 text-white"
>
{loading ? "Loading..." : "Fetch account"}
</button>
{error ? <div className="text-xs text-red-300">{error}</div> : null}
{result ? (
<pre className="text-xs bg-zinc-950 p-3 rounded">
{JSON.stringify(result, null, 2)}
</pre>
) : null}
</div>
);
}This component is honest.
It does not pretend every lookup succeeds.
It does not decode program data blindly.
It just shows the chain facts cleanly.
That is the correct first read tool.
Step 3: Decode Only After You Know The Program
Once you know an account exists and belongs to the program you expect, then decoding makes sense.
For program-specific accounts, the Kit docs and program clients make this easier.
Example with the token program:
import { decodeMint } from "@solana-program/token";
import { fetchExistingEncodedAccount } from "@/lib/solana/rpc";
export async function fetchMintSummary(input: string) {
const encodedAccount = await fetchExistingEncodedAccount(input);
const mintAccount = decodeMint(encodedAccount);
return {
address: mintAccount.address,
lamports: mintAccount.lamports.toString(),
decimals: mintAccount.data.decimals,
supply: mintAccount.data.supply.toString(),
};
}Read that in order:
- fetch the encoded account
- assert it exists
- decode with the program client helper
- normalize again for the UI
That is the safe decoding path.
The Failure Modes To Avoid
Decoding before checking ownership
If you decode arbitrary bytes with the wrong codec, your client logic becomes nonsense quickly.
The account may exist and still be the wrong thing.
Throwing on every missing account
That makes ordinary read states feel like fatal errors.
Use missing-account behavior deliberately.
Passing raw RPC data straight to components
That leaks transport details into UI code.
Normalize first.
Always.
What You Can Do After This
You can fetch account data with a stable Kit helper, distinguish missing accounts from real failures, and decode program data only after the read path proves you are looking at the right account.
That is the right base for the transaction-building lessons that come next.
Quick Check
Why is `MaybeEncodedAccount` a useful shape instead of pointless ceremony?
Why should decoding happen only after the client knows the account exists and belongs to the expected program?
Up Next in Solana Kit Clients
Transaction Message Building and Signers
Use the same client discipline on the write path by building transaction messages in the only order that still makes signatures meaningful.