Ця інструкція проведе вас через створення мінімального frontend-застосунку, який підключає гаманець, читає дані з Devnet і відправляє транзакцію. Ви отримаєте робочий прототип, який можна розширювати.

Середовище: Node.js 18+, npm 9+, ОС без значення.
Кластер: Devnet (https://api.devnet.solana.com).
Версії ключових залежностей: @solana/[email protected], @solana/[email protected], @solana/[email protected], React 18.3.1, Vite 5.4.2.
Передумови: встановлений Phantom (або інший Solana-гаманець) у браузері, базове знайомство з React, завершений матеріал Як підключитися до Devnet з TypeScript.

Вибір стеку

React + Vite — рекомендований початковий стек

React є фактичним стандартом для Solana-frontend: усі офіційні бібліотеки гаманців (Wallet Adapter) написані саме для нього. Vite дає швидкий старт без конфігурації webpack і коректно працює з TypeScript з коробки.

Чому не Next.js на цьому етапі: SSR (server-side rendering) у контексті блокчейн-застосунків додає складність — гаманець існує лише в браузері, тому клієнтські компоненти все одно будуть домінувати. Для першого прототипу це зайвий шар.

Встановлення та налаштування

Створіть проєкт і встановіть залежності:

npm create vite@latest solana-ui -- --template react-ts
cd solana-ui
npm install @solana/[email protected] @solana/[email protected] @solana/[email protected] @solana/[email protected] @solana/[email protected]

Після встановлення перевірте, що у файлі package.json версії збігаються з зазначеними вище. Розбіжності навіть у мінорних версіях Wallet Adapter можуть призвести до несумісності типів.

У файлі src/main.tsx оберіть додаток провайдерами:

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { PhantomWalletAdapter } from '@solana/wallet-adapter-wallets';
import '@solana/wallet-adapter-react-ui/styles.css';

const network = WalletAdapterNetwork.Devnet;
const endpoint = 'https://api.devnet.solana.com';

const wallets = [new PhantomWalletAdapter()];

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <ConnectionProvider endpoint={endpoint}>
      <WalletProvider wallets={wallets} autoConnect>
        <WalletModalProvider>
          <App />
        </WalletModalProvider>
      </WalletProvider>
    </ConnectionProvider>
  </React.StrictMode>
);

Очікуваний результат: після npm run dev сторінка відкривається без помилок у консолі. Провайдери готові до використання в будь-якому компоненті.

Базовий компонент підключення гаманця

Кнопка Connect Wallet

Wallet Adapter-react-ui постачається з готовим компонентом WalletMultiButton. Він автоматично визначає наявні гаманці в браузері, показує випадаюче меню і керує станом підключення.

У файлі src/App.tsx:

import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';

function App() {
  return (
    <div style={{ padding: '2rem' }}>
      <WalletMultiButton />
    </div>
  );
}

export default App;

Перевірка: натисніть кнопку — має з'явитися модальне вікно з Phantom (якщо встановлено). Після підключення кнопка змінює текст на скорочену адресу гаманця.

Відображення адреси та балансу

Для доступу до підключеного гаманця використовуйте хук useWallet. Для отримання балансу — useConnection разом із методом connection.getBalance.

import { useWallet } from '@solana/wallet-adapter-react';
import { useConnection } from '@solana/wallet-adapter-react';
import { useState, useEffect } from 'react';

function WalletInfo() {
  const { publicKey, connected } = useWallet();
  const { connection } = useConnection();
  const [balance, setBalance] = useState<number | null>(null);

  useEffect(() => {
    if (!publicKey) {
      setBalance(null);
      return;
    }
    connection.getBalance(publicKey).then((lamports) => {
      setBalance(lamports / 1e9);
    });
  }, [publicKey, connection]);

  if (!connected) return <p>Підключіть гаманець</p>;

  return (
    <div>
      <p>Адреса: {publicKey?.toBase58()}</p>
      <p>Баланс: {balance !== null ? `${balance} SOL` : 'Завантаження...'}</p>
    </div>
  );
}

Нюанс: баланс повертається в lamports (1 SOL = 1 000 000 000 lamports). Ділення на 1e9 дає значення в SOL. Це концептуальний приклад — у production варто використовувати BN для точних обчислень і уникати плаваючої крапки.

Перевірка: якщо баланс на Devnet дорівнює 0, отримайте тестові токени через faucet (наприклад, solana airdrop 2 <адреса> у CLI). Після оновлення сторінки баланс має відобразитися.

Відображення даних з on-chain

Завантаження стану програми

Найпростіший on-chain-дані, який можна прочитати без розгорнутої програми — інформація про обліковий запис (account info). Для прикладу зчитаємо розмір та власника довільного облікового запису за його адресою.

import { useConnection } from '@solana/wallet-adapter-react';
import { useState, useEffect } from 'react';
import { PublicKey } from '@solana/web3.js';

function AccountInspector({ address }: { address: string }) {
  const { connection } = useConnection();
  const [info, setInfo] = useState<{ owner: string; lamports: number; space: number } | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;
    connection.getParsedAccountInfo(new PublicKey(address)).then((result) => {
      if (cancelled) return;
      if (result.value) {
        setInfo({
          owner: result.value.owner.toBase58(),
          lamports: result.value.lamports,
          space: result.value.data.length,
        });
      } else {
        setError('Обліковий запис не знайдено');
      }
    }).catch((err) => {
      if (!cancelled) setError(err.message);
    });
    return () => { cancelled = true; };
  }, [address, connection]);

  if (error) return <p>Помилка: {error}</p>;
  if (!info) return <p>Завантаження...</p>;

  return (
    <div>
      <p>Власник: {info.owner}</p>
      <p>Lamports: {info.lamports}</p>
      <p>Розмір даних: {info.space} байт</p>
    </div>
  );
}

Нюанс: прапорець cancelled запобігає оновленню стану після розмонтування компонента. Без нього React видасть попередження у режимі StrictMode. Це концептуальний приклад — у production використовуйте AbortController або бібліотеку на кшталт react-query для кешування та скасування запитів.

Відображення у таблиці або картках

Якщо ви зчитуєте список облікових записів, таблиця підходить для структурованих даних, картки — для візуально різнорідних елементів. Нижче — мінімальна таблиця для масиву адрес.

function AccountTable({ addresses }: { addresses: string[] }) {
  return (
    <table>
      <thead>
        <tr>
          <th>Адреса</th>
          <th>Власник</th>
          <th>Lamports</th>
        </tr>
      </thead>
      <tbody>
        {addresses.map((addr) => (
          <AccountRow key={addr} address={addr} />
        ))}
      </tbody>
    </table>
  );
}

Обмеження: не завантажуйте більше 20–30 записів одночасно через один RPC-виклик на Devnet — це призведе до таймауту. Для більших обсягів потрібна пагінація або індексування, що виходить за межі цього матеріалу.

Форма для відправки транзакції

Поля вводу, валідація, відправка

Найпростіша транзакція на Solana — переказ SOL на іншу адресу. Форма містить два поля: адреса одержувача та сума.

import { useWallet } from '@solana/wallet-adapter-react';
import { useConnection } from '@solana/wallet-adapter-react';
import { PublicKey, LAMPORTS_PER_SOL, Transaction, SystemProgram } from '@solana/web3.js';
import { useState } from 'react';

function TransferForm() {
  const { publicKey, sendTransaction } = useWallet();
  const { connection } = useConnection();
  const [recipient, setRecipient] = useState('');
  const [amount, setAmount] = useState('');
  const [status, setStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
  const [txSignature, setTxSignature] = useState<string | null>(null);

  const isValidRecipient = (() => {
    try {
      new PublicKey(recipient);
      return true;
    } catch {
      return false;
    }
  })();

  const isValidAmount = parseFloat(amount) > 0;

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!publicKey || !isValidRecipient || !isValidAmount) return;

    setStatus('sending');
    setTxSignature(null);

    try {
      const transaction = new Transaction().add(
        SystemProgram.transfer({
          fromPubkey: publicKey,
          toPubkey: new PublicKey(recipient),
          lamports: parseFloat(amount) * LAMPORTS_PER_SOL,
        })
      );

      const signature = await sendTransaction(transaction, connection);
      await connection.confirmTransaction(signature, 'confirmed');

      setTxSignature(signature);
      setStatus('success');
    } catch {
      setStatus('error');
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Адреса одержувача:</label><br />
        <input
          value={recipient}
          onChange={(e) => setRecipient(e.target.value)}
          placeholder="Наприклад: 7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU"
        />
        {recipient && !isValidRecipient && <p style={{ color: 'red' }}>Некоректна адреса</p>}
      </div>
      <div>
        <label>Сума (SOL):</label><br />
        <input
          type="number"
          step="0.001"
          min="0"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
        />
      </div>
      <button type="submit" disabled={!isValidRecipient || !isValidAmount || status === 'sending'}>
        Відправити
      </button>
    </form>
  );
}

Важливо: валідація адреси через конструктор new PublicKey() перевіряє формат, але не гарантує, що обліковий запис існує. Транзакція на неіснуючу адресу буде успішною — кошти просто створять цей обліковий запис.

Індикатор завантаження та статусу

Додайте відображення статусу під формою. Змінна status вже визначена в компоненті вище — залишиться додати JSX:

{status === 'sending' && <p>Транзакція відправляється...</p>}
{status === 'success' && txSignature && (
  <p>
    Транзакція підтверджена: {txSignature}
  </p>
)}
{status === 'error' && <p style={{ color: 'red' }}>Помилка відправки транзакції</p>}

Перевірка: відправте 0.01 SOL на власну адресу (скопіюйте її з блоку WalletInfo). Після підтвердження статус має змінитися на success, а підпис транзакції має бути видно. Перевірте підпис у Solana Explorer на Devnet.

Обмеження цього підходу: confirmTransaction із рівнем confirmed чекає одного підтвердження. Для фінансових операцій у production використовують рівень finalized або оптимістичне відображення з подальшою перевіркою.

Типові помилки

UI не оновлюється після транзакції

Причина: баланс завантажується один раз у useEffect із залежністю від publicKey. Після відправки транзакції publicKey не змінюється, тому ефект не спрацьовує повторно.

Рішення: додайте сигнал для повторного завантаження. Найпростіший варіант — лічильник:

const [refreshCounter, setRefreshCounter] = useState(0);

useEffect(() => {
  // ... завантаження балансу
}, [publicKey, connection, refreshCounter]);

// Після успішної транзакції:
setRefreshCounter((c) => c + 1);

У production для цього використовують react-query з invalidateQueries або підписку на WebSocket-оновлення через connection.onAccountChange.

Помилки мережі не відображаються користувачу

Причина: у прикладі вище блок catch просто встановлює status === 'error' без збереження тексту помилки. Користувач бачить «Помилка відправки транзакції», але не знає чому — недостатньо коштів, відхилено гаманцем, таймаут RPC тощо.

Мінімальне покращення: зберігайте повідомлення:

const [errorMessage, setErrorMessage] = useState<string | null>(null);

// У catch:
} catch (err: unknown) {
  setStatus('error');
  if (err instanceof Error) {
    setErrorMessage(err.message);
  } else {
    setErrorMessage('Невідома помилка');
  }
}

// У JSX:
{status === 'error' && <p style={{ color: 'red' }}>Помилка: {errorMessage}</p>}

Обмеження: повноцінна обробка помилок гаманця (відхилення користувачем, таймаут підписання, невідповідність мережі) вимагає системного підходу з розрізненням типів помилок. Ця тема детально розкрита в наступному матеріалі.

Наступний крок: після того, як базовий UI працює на Devnet, перейдіть до матеріалу про обробку помилок гаманця у frontend — це зробить ваш застосунок придатним для реального використання, а не лише для демонстрації.

Джерела