Разработка фронтенда dApp на React
Главная ошибка при разработке dApp фронтенда — переносить архитектурные паттерны из обычных веб-приложений. В dApp нет «пользователя» в классическом смысле, нет сессий, нет авторизации через сервер. Есть кошелёк, есть подписи, есть транзакции которые могут подвиснуть на час. Это принципиально меняет как устроено state management и UX.
Стек: почему wagmi + viem, а не ethers.js напрямую
wagmi v2 — это не просто обёртка над viem. Это опinionated слой для 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 → included в блок → 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 actions (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 providers, не нужны отдельные пакеты для каждого кошелька







