← Back to articles
June 202611 min read

How to Manage Frontend State for Wallet and On-Chain Data in Web3 Apps

A practical guide to separating wallet state, app state, and server cache state in Web3 dashboards. Covers wagmi, Zustand, TanStack Query, and architecture patterns that scale.

Web3State ManagementReact QueryZustand

Three types of state in Web3 apps

Web3 frontends juggle three distinct state categories that should never be mixed. Wallet state covers connection status, address, chain ID, and signing capabilities — managed by wagmi. App state covers UI preferences, filters, modals, and form inputs — managed by React state, Zustand, or similar. Server/cache state covers on-chain reads, API responses, and indexed data — managed by TanStack Query.

Blurring these boundaries leads to stale data, unnecessary re-renders, and components that are impossible to test in isolation.

Managing wallet connection state

wagmi is the source of truth for wallet state. Do not duplicate address or chainId in a global store. Components that need wallet data import hooks directly.

// lib/wagmi-config.ts
import { createConfig, http } from 'wagmi';
import { mainnet, base } from 'wagmi/chains';
import { coinbaseWallet, injected, walletConnect } from 'wagmi/connectors';

export const config = createConfig({
  chains: [mainnet, base],
  connectors: [
    injected(),
    coinbaseWallet({ appName: 'My Dashboard' }),
    walletConnect({ projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID! }),
  ],
  transports: {
    [mainnet.id]: http(process.env.NEXT_PUBLIC_ALCHEMY_URL),
    [base.id]: http(),
  },
  ssr: true,
});

Wrap only the layout that needs wallet context. Public pages should not import wagmi hooks at all.

When to use each state tool

**React useState** — local UI state: dropdown open, tab selection, form inputs. Keep it colocated in the component.

**React Context** — rarely needed for wallet state (wagmi handles it). Use Context only for app-wide UI theming or feature flags.

**Zustand** — dashboard filters, sidebar state, notification queue, user preferences. Lightweight and avoids Context re-render issues.

**Redux** — large teams with complex cross-feature state. Overkill for most Web3 dashboards unless you already use it.

**TanStack Query** — all server and on-chain data. This is non-negotiable for production dashboards.

// stores/dashboard-store.ts
import { create } from 'zustand';

interface DashboardStore {
  timeRange: '24h' | '7d' | '30d' | 'all';
  selectedTokens: string[];
  setTimeRange: (range: DashboardStore['timeRange']) => void;
  toggleToken: (address: string) => void;
}

export const useDashboardStore = create<DashboardStore>((set) => ({
  timeRange: '7d',
  selectedTokens: [],
  setTimeRange: (timeRange) => set({ timeRange }),
  toggleToken: (address) =>
    set((state) => ({
      selectedTokens: state.selectedTokens.includes(address)
        ? state.selectedTokens.filter((t) => t !== address)
        : [...state.selectedTokens, address],
    })),
}));

Avoiding unnecessary re-renders

Split connected and disconnected views into separate component trees. A parent that subscribes to useAccount re-renders all children on every wallet event.

// components/dashboard/DashboardShell.tsx
'use client';

import { useAccount } from 'wagmi';
import { PublicDashboard } from './PublicDashboard';
import { ConnectedDashboard } from './ConnectedDashboard';

export function DashboardShell() {
  const { isConnected } = useAccount();

  return isConnected ? <ConnectedDashboard /> : <PublicDashboard />;
}

Use React Query's select option to derive only the data a component needs. Memoize expensive chart data transformations with useMemo.

const { data: totalValue } = useQuery({
  queryKey: ['portfolio', address],
  queryFn: () => fetchPortfolio(address),
  select: (data) => data.positions.reduce((sum, p) => sum + p.usdValue, 0),
  enabled: !!address,
});

Maintainable architecture as features grow

Organize by feature, not by file type. Each feature folder owns its components, hooks, and types.

src/
  features/
    wallet/
      components/ConnectButton.tsx
      components/NetworkGuard.tsx
    portfolio/
      hooks/useTokenBalances.ts
      hooks/usePortfolioValue.ts
      components/PortfolioSummary.tsx
    transactions/
      hooks/useTransactionHistory.ts
      components/TransactionTable.tsx
  lib/
    wagmi-config.ts
    api-client.ts
  app/
    dashboard/page.tsx

The data layer never imports from the presentation layer. Hooks return typed data; components render it.

Practical architecture example

// features/portfolio/hooks/usePortfolioValue.ts
import { useReadContracts } from 'wagmi';
import { useQuery } from '@tanstack/react-query';
import { erc20Abi } from 'viem';

export function usePortfolioValue(address: `0x${string}` | undefined, tokens: Token[]) {
  const { data: balances } = useReadContracts({
    contracts: tokens.map((t) => ({
      address: t.address,
      abi: erc20Abi,
      functionName: 'balanceOf',
      args: address ? [address] : undefined,
    })),
    query: { enabled: !!address, staleTime: 30_000 },
  });

  const { data: prices } = useQuery({
    queryKey: ['prices', tokens.map((t) => t.coingeckoId)],
    queryFn: () => fetchPrices(tokens.map((t) => t.coingeckoId)),
    staleTime: 60_000,
  });

  if (!balances || !prices) return { totalUsd: 0, isLoading: true };

  const totalUsd = balances.reduce((sum, bal, i) => {
    const amount = Number(bal.result) / 10 ** tokens[i].decimals;
    return sum + amount * (prices[tokens[i].coingeckoId] ?? 0);
  }, 0);

  return { totalUsd, isLoading: false };
}

Mistakes to avoid

Storing on-chain data in Zustand instead of React Query — you lose caching, deduplication, and background refetch. Calling useBalance in every child component instead of a parent data hook. Using useEffect for data fetching. Putting wallet private keys or RPC URLs in client state. Creating a monolithic "Web3Context" that re-renders the entire app on any change.

FAQ

**Can I use SWR instead of React Query?** Yes, but wagmi v2 integrates natively with TanStack Query. Mixing libraries adds complexity without benefit.

**Should wallet state live in Zustand?** No. wagmi already provides reactive wallet state with proper persistence and reconnection.

**How do I test components without a wallet?** Mock wagmi hooks with viem's test utilities or use wagmi's mock connector in Storybook.

**When should I lift state up?** When two sibling components need the same server data — lift to a shared hook with the same queryKey, not to a global store.

**How do I handle multi-chain state?** Pass chainId as part of every query key. Never assume mainnet defaults silently.

Want to work together? I build Web3 dashboards and DeFi interfaces.