Ця інструкція проведе вас через створення мінімального 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 — це зробить ваш застосунок придатним для реального використання, а не лише для демонстрації.