Skip to content

${redev}

Published 12/4/2025 · 7 min read

Tags: solana , svelte , web3 , tokens , components

Let’s build real Svelte components that interact with Solana programs. We’ll create a token balance display, a transfer form, and real-time updates.

Token Balance Store

First, a store that tracks token balances:

// src/lib/stores/tokens.ts
import { writable, derived, get } from "svelte/store";
import { wallet, connection } from "./wallet";
import {
  getAssociatedTokenAddress,
  getAccount,
  TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import { PublicKey } from "@solana/web3.js";

// Known tokens on devnet
export const TOKENS = {
  USDC: {
    mint: new PublicKey("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"),
    symbol: "USDC",
    decimals: 6,
  },
};

interface TokenBalance {
  mint: string;
  symbol: string;
  balance: number;
  uiBalance: string;
  ata: string;
}

function createTokenStore() {
  const { subscribe, set, update } = writable<TokenBalance[]>([]);

  let refreshInterval: ReturnType<typeof setInterval> | null = null;

  async function fetchBalances() {
    const $wallet = get(wallet);
    const $connection = get(connection);

    if (!$wallet.publicKey) {
      set([]);
      return;
    }

    const balances: TokenBalance[] = [];

    for (const [key, token] of Object.entries(TOKENS)) {
      try {
        const ata = await getAssociatedTokenAddress(
          token.mint,
          $wallet.publicKey
        );

        const account = await getAccount($connection, ata);
        const balance = Number(account.amount);
        const uiBalance = (balance / Math.pow(10, token.decimals)).toFixed(2);

        balances.push({
          mint: token.mint.toBase58(),
          symbol: token.symbol,
          balance,
          uiBalance,
          ata: ata.toBase58(),
        });
      } catch (err) {
        // Account doesn't exist = 0 balance
        const ata = await getAssociatedTokenAddress(
          token.mint,
          $wallet.publicKey
        );

        balances.push({
          mint: token.mint.toBase58(),
          symbol: token.symbol,
          balance: 0,
          uiBalance: "0.00",
          ata: ata.toBase58(),
        });
      }
    }

    set(balances);
  }

  return {
    subscribe,

    refresh: fetchBalances,

    startPolling(intervalMs = 10000) {
      this.stopPolling();
      fetchBalances();
      refreshInterval = setInterval(fetchBalances, intervalMs);
    },

    stopPolling() {
      if (refreshInterval) {
        clearInterval(refreshInterval);
        refreshInterval = null;
      }
    },
  };
}

export const tokenBalances = createTokenStore();

// Derived store for specific token
export function getTokenBalance(symbol: string) {
  return derived(
    tokenBalances,
    ($balances) => $balances.find((b) => b.symbol === symbol) ?? null
  );
}

Token Balance Component

Display token balances:

<!-- src/lib/components/TokenBalances.svelte -->
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import { wallet } from '$lib/stores/wallet';
  import { tokenBalances } from '$lib/stores/tokens';

  onMount(() => {
    if ($wallet.connected) {
      tokenBalances.startPolling();
    }
  });

  onDestroy(() => {
    tokenBalances.stopPolling();
  });

  // Restart polling when wallet connects/disconnects
  $: if ($wallet.connected) {
    tokenBalances.startPolling();
  } else {
    tokenBalances.stopPolling();
  }
</script>

{#if $wallet.connected}
  <div class="token-list">
    <h3>Token Balances</h3>

    {#if $tokenBalances.length === 0}
      <p class="loading">Loading...</p>
    {:else}
      {#each $tokenBalances as token}
        <div class="token-row">
          <span class="symbol">{token.symbol}</span>
          <span class="balance">{token.uiBalance}</span>
        </div>
      {/each}
    {/if}

    <button class="refresh" on:click={() => tokenBalances.refresh()}>
      Refresh
    </button>
  </div>
{/if}

<style>
  .token-list {
    background: #1a1a2e;
    border-radius: 0.75rem;
    padding: 1.5rem;
    min-width: 200px;
  }

  h3 {
    margin: 0 0 1rem 0;
    font-size: 0.9rem;
    color: #888;
    text-transform: uppercase;
    letter-spacing: 0.05em;
  }

  .token-row {
    display: flex;
    justify-content: space-between;
    padding: 0.75rem 0;
    border-bottom: 1px solid #333;
  }

  .token-row:last-of-type {
    border-bottom: none;
  }

  .symbol {
    font-weight: 600;
    color: #14F195;
  }

  .balance {
    font-family: 'SF Mono', monospace;
    color: #fff;
  }

  .loading {
    color: #666;
    font-style: italic;
  }

  .refresh {
    margin-top: 1rem;
    width: 100%;
    padding: 0.5rem;
    background: #333;
    border: none;
    border-radius: 0.5rem;
    color: #888;
    cursor: pointer;
  }

  .refresh:hover {
    background: #444;
    color: #fff;
  }
</style>

Token Transfer Component

A form to send USDC:

<!-- src/lib/components/SendToken.svelte -->
<script lang="ts">
  import { wallet, connection } from '$lib/stores/wallet';
  import { tokenBalances, TOKENS } from '$lib/stores/tokens';
  import {
    getAssociatedTokenAddress,
    createTransferInstruction,
    createAssociatedTokenAccountInstruction,
    TOKEN_PROGRAM_ID,
    ASSOCIATED_TOKEN_PROGRAM_ID
  } from '@solana/spl-token';
  import { Transaction, PublicKey } from '@solana/web3.js';

  export let tokenSymbol = 'USDC';

  let recipient = '';
  let amount = '';
  let sending = false;
  let error: string | null = null;
  let success: string | null = null;

  $: token = TOKENS[tokenSymbol as keyof typeof TOKENS];
  $: balance = $tokenBalances.find(t => t.symbol === tokenSymbol);

  async function send() {
    if (!$wallet.wallet || !$wallet.publicKey || !token) return;

    sending = true;
    error = null;
    success = null;

    try {
      // Validate recipient
      let recipientPubkey: PublicKey;
      try {
        recipientPubkey = new PublicKey(recipient);
      } catch {
        throw new Error('Invalid recipient address');
      }

      // Calculate amount in smallest units
      const amountNum = parseFloat(amount);
      if (isNaN(amountNum) || amountNum <= 0) {
        throw new Error('Invalid amount');
      }
      const amountInSmallestUnit = BigInt(
        Math.floor(amountNum * Math.pow(10, token.decimals))
      );

      // Check balance
      if (balance && amountInSmallestUnit > BigInt(balance.balance)) {
        throw new Error('Insufficient balance');
      }

      // Get ATAs
      const sourceAta = await getAssociatedTokenAddress(
        token.mint,
        $wallet.publicKey
      );
      const destAta = await getAssociatedTokenAddress(
        token.mint,
        recipientPubkey
      );

      // Build transaction
      const tx = new Transaction();

      // Check if destination ATA exists
      const destAccount = await $connection.getAccountInfo(destAta);
      if (!destAccount) {
        // Create it (sender pays)
        tx.add(
          createAssociatedTokenAccountInstruction(
            $wallet.publicKey,  // payer
            destAta,            // ata to create
            recipientPubkey,    // owner of new ata
            token.mint,         // token mint
            TOKEN_PROGRAM_ID,
            ASSOCIATED_TOKEN_PROGRAM_ID
          )
        );
      }

      // Add transfer
      tx.add(
        createTransferInstruction(
          sourceAta,
          destAta,
          $wallet.publicKey,
          amountInSmallestUnit
        )
      );

      // Get blockhash and sign
      const { blockhash } = await $connection.getLatestBlockhash();
      tx.recentBlockhash = blockhash;
      tx.feePayer = $wallet.publicKey;

      const signedTx = await $wallet.wallet.signTransaction(tx);
      const signature = await $connection.sendRawTransaction(signedTx.serialize());
      await $connection.confirmTransaction(signature, 'confirmed');

      success = signature;
      recipient = '';
      amount = '';

      // Refresh balances
      tokenBalances.refresh();

    } catch (err) {
      error = (err as Error).message;
    } finally {
      sending = false;
    }
  }
</script>

<div class="send-form">
  <h3>Send {tokenSymbol}</h3>

  {#if balance}
    <p class="available">Available: {balance.uiBalance} {tokenSymbol}</p>
  {/if}

  <input
    type="text"
    bind:value={recipient}
    placeholder="Recipient wallet address"
    disabled={sending}
  />

  <div class="amount-row">
    <input
      type="number"
      bind:value={amount}
      placeholder="0.00"
      step="0.01"
      min="0"
      disabled={sending}
    />
    <span class="symbol">{tokenSymbol}</span>
  </div>

  <button
    on:click={send}
    disabled={sending || !recipient || !amount || !$wallet.connected}
  >
    {sending ? 'Sending...' : `Send ${tokenSymbol}`}
  </button>

  {#if error}
    <p class="error">{error}</p>
  {/if}

  {#if success}
    <p class="success">
      Sent!
      <a
        href={`https://explorer.solana.com/tx/${success}?cluster=devnet`}
        target="_blank"
      >
        View →
      </a>
    </p>
  {/if}
</div>

<style>
  .send-form {
    background: #1a1a2e;
    border-radius: 0.75rem;
    padding: 1.5rem;
    display: flex;
    flex-direction: column;
    gap: 1rem;
  }

  h3 {
    margin: 0;
    color: #fff;
  }

  .available {
    color: #888;
    font-size: 0.9rem;
    margin: 0;
  }

  input {
    padding: 0.75rem 1rem;
    background: #0f0f1a;
    border: 1px solid #333;
    border-radius: 0.5rem;
    color: #fff;
    font-size: 1rem;
  }

  input:focus {
    outline: none;
    border-color: #14F195;
  }

  .amount-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
  }

  .amount-row input {
    flex: 1;
  }

  .amount-row .symbol {
    color: #888;
    font-weight: 600;
  }

  button {
    padding: 1rem;
    background: #14F195;
    color: #000;
    border: none;
    border-radius: 0.5rem;
    font-weight: 600;
    font-size: 1rem;
    cursor: pointer;
  }

  button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

  .error {
    color: #f87171;
    margin: 0;
  }

  .success {
    color: #14F195;
    margin: 0;
  }

  .success a {
    color: #9945FF;
  }
</style>

Real-Time Updates with WebSockets

For instant updates, subscribe to account changes:

// src/lib/stores/tokens.ts - add to the store

async function subscribeToBalances() {
  const $wallet = get(wallet);
  const $connection = get(connection);

  if (!$wallet.publicKey) return;

  const subscriptionIds: number[] = [];

  for (const token of Object.values(TOKENS)) {
    const ata = await getAssociatedTokenAddress(token.mint, $wallet.publicKey);

    const subId = $connection.onAccountChange(
      ata,
      (accountInfo) => {
        // Account changed - refresh balances
        fetchBalances();
      },
      "confirmed"
    );

    subscriptionIds.push(subId);
  }

  return () => {
    // Cleanup function
    subscriptionIds.forEach((id) => {
      $connection.removeAccountChangeListener(id);
    });
  };
}

Putting It Together

A complete token management page:

<!-- src/routes/tokens/+page.svelte -->
<script>
  import WalletButton from '$lib/components/WalletButton.svelte';
  import TokenBalances from '$lib/components/TokenBalances.svelte';
  import SendToken from '$lib/components/SendToken.svelte';
  import { wallet } from '$lib/stores/wallet';
</script>

<svelte:head>
  <title>Token Manager</title>
</svelte:head>

<div class="container">
  <header>
    <h1>Token Manager</h1>
    <WalletButton />
  </header>

  {#if $wallet.connected}
    <main>
      <div class="grid">
        <TokenBalances />
        <SendToken tokenSymbol="USDC" />
      </div>
    </main>
  {:else}
    <div class="connect-prompt">
      <p>Connect your wallet to manage tokens</p>
    </div>
  {/if}
</div>

<style>
  .container {
    max-width: 800px;
    margin: 0 auto;
    padding: 2rem;
  }

  header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 2rem;
  }

  h1 {
    margin: 0;
  }

  .grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 1.5rem;
  }

  .connect-prompt {
    text-align: center;
    padding: 4rem;
    color: #888;
  }

  @media (max-width: 640px) {
    .grid {
      grid-template-columns: 1fr;
    }
  }
</style>

What You Learned

  • Creating Svelte stores for token data
  • Polling vs WebSocket subscriptions
  • Building transfer forms with proper validation
  • Checking and creating Associated Token Accounts
  • Reactive updates when balances change

Next Up

Things go wrong. Let’s look at proper error handling and transaction confirmation patterns.

Related Articles

  • Form Handling: Moving from Vue to Svelte

    A practical guide to translating Vue form patterns to Svelte, covering two-way binding, validation, async submission, and what actually works better in each framework.

  • Building a Modal: Vue vs Svelte

    A side-by-side comparison of building a modal component in Vue 3 and Svelte 5, exploring the differences in reactivity, props, and component patterns.