Розробка фронтенда dApp на React
Головна ошибка при розробці фронтенда dApp — переносити архітектурні паттерни з звичайних веб-додатків. У dApp немає «користувача» в класичному сенсі, немає сесій, немає авторизації через сервер. Є гаманець, є підписи, є трансакції які можуть зависнути на годину. Це принципово змінює як влаштовано state management та UX.
Stack: чому wagmi + viem, а не ethers.js напрямку
wagmi v2 — це не просто обёртка над viem. Це opinionated шар для React з:
- Автоматичним управлінням connection state (connected/disconnecting/reconnecting)
- Cache invalidation після трансакцій (через TanStack Query під капотом)
- SSR-сумісністю з коробки
- Type-safe ABI encoding через viem
viem замінив ethers.js v5 як де-факто стандарт для low-level операцій. Кращий TypeScript support, tree-shakeable, значно менший bundle size.
// wagmi v2 конфігурація
import { createConfig, http } from "wagmi";
import { mainnet, arbitrum, optimism } from "wagmi/chains";
import { injected, metaMask, coinbaseWallet } from "wagmi/connectors";
export const config = createConfig({
chains: [mainnet, arbitrum, optimism],
connectors: [
injected(),
metaMask(),
coinbaseWallet({ appName: "MyDApp" }),
],
transports: {
[mainnet.id]: http(process.env.NEXT_PUBLIC_RPC_MAINNET),
[arbitrum.id]: http(process.env.NEXT_PUBLIC_RPC_ARBITRUM),
[optimism.id]: http(process.env.NEXT_PUBLIC_RPC_OPTIMISM),
},
});
Управління станом трансакцій
Трансакція в EVM — це не HTTP запрос. Вона проходить стадії: pending в mempool → включена в блок → confirmed (N confirmations). Користувач повинен розуміти, що відбувається на кожному етапі.
Паттерн для tracking трансакцій:
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
function MintButton() {
const { writeContract, data: hash, isPending } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash,
confirmations: 2, // чекаємо 2 блоки
});
// Три стани UI:
// isPending = трансакція відправлена, чекаємо підпис в гаманці
// isConfirming = підписана, чекаємо включення в блок
// isSuccess = підтверджена
return (
<button disabled={isPending || isConfirming}>
{isPending ? "Підписуємо..." : isConfirming ? "Чекаємо блок..." : "Mint"}
</button>
);
}
Читаємо дані з контракту: useReadContract та multicall
Для читання кількох значень використовуємо useReadContracts з multicall — це батчить запити в один RPC call:
import { useReadContracts } from "wagmi";
const { data } = useReadContracts({
contracts: [
{ ...tokenContract, functionName: "balanceOf", args: [userAddress] },
{ ...tokenContract, functionName: "totalSupply" },
{ ...stakingContract, functionName: "pendingRewards", args: [userAddress] },
],
// Автоматично використовує multicall3 якщо доступен
});
Важливо: wagmi використовує TanStack Query для кешування. За замовчуванням дані вважаються свіжими 4 секунди. Для DeFi dashboards з fast-changing data знижуємо staleTime до 0 та включаємо refetchInterval.
Обробка помилок: типічні випадки
User rejected — користувач нажав Cancel в MetaMask. error.code === 4001. Просто закриваємо модалку, не показуємо error toast.
Insufficient funds — error.code === -32000. Показуємо зрозуміле повідомлення з сумою, яка не вистачає.
Revert з reason string — контракт виписав require("Insufficient allowance"). Парсимо через viem:
import { ContractFunctionRevertedError } from "viem";
if (error instanceof ContractFunctionRevertedError) {
const reason = error.data?.errorName ?? error.shortMessage;
// Показуємо reason користувачу
}
Stuck transaction — трансакція в pending > 5 хвилин. Потрібен UI для speed up (replace з тим же nonce та +10% gas) або cancel (відправити 0 ETH собі з тим же nonce). wagmi не надає це з коробки, пишемо через viem sendTransaction з явним nonce.
Wallet UX: розповсюджені помилки
Не перевіряємо chainId — користувач підключений до mainnet, а dApp працює на Arbitrum. Без явної перевірки та switchChain call користувач побачить cryptic error. Використовуємо хук useChainId та компонент-гард:
import { useChainId, useSwitchChain } from "wagmi";
function ChainGuard({ requiredChainId, children }) {
const chainId = useChainId();
const { switchChain } = useSwitchChain();
if (chainId !== requiredChainId) {
return (
<button onClick={() => switchChain({ chainId: requiredChainId })}>
Переключити мережу
</button>
);
}
return children;
}
Hydration mismatch при SSR — wallet state на сервері невідомий, на клієнті — підключений. Next.js виписує hydration error. Рішення: оборачуємо wallet-dependent компоненти в dynamic import з ssr: false, або використовуємо mounted state.
EIP-712 підписи (typed data)
Для off-chain дій (permit, gasless mint, snapshot voting) використовуємо typed data signatures замість звичайних трансакцій:
import { useSignTypedData } from "wagmi";
const { signTypedData } = useSignTypedData();
const signature = await signTypedData({
domain: { name: "MyProtocol", version: "1", chainId: 1, verifyingContract },
types: { Order: [{ name: "tokenId", type: "uint256" }, { name: "price", type: "uint256" }] },
primaryType: "Order",
message: { tokenId: 42n, price: parseEther("0.1") },
});
Структура проекту
src/
abi/ # JSON ABI файли контрактів
config/ # wagmi config, chain configs
contracts/ # typed contract instances (viem getContract)
hooks/ # кастомні хуки для кожного контракту
useStaking.ts
useTokenBalance.ts
components/ # UI компоненти
lib/ # утиліти (formatUnits wrappers, address shortener)
Генерація типів з ABI через wagmi CLI (wagmi generate) — усуває ручне написання типів та автоматично генерує хуки.
Performance: що важливо в dApp
- Bundle size: viem + wagmi ≈ 60KB gzipped. Уникаємо дублювання (не тащимо ethers.js якщо вже є viem)
- RPC rate limits: не робимо запити в useEffect без debounce, використовуємо wagmi кеш
-
Suspense: TanStack Query під wagmi підтримує Suspense — використовуємо для скелетонів замість ручних
isLoadingchecks -
Wallet detection:
injected()коннектор знаходить всі injected провайдери, не потрібні окремі пакети для кожного гаманця







