Token Selection
This example demonstrates how to build React components for selecting source chains and tokens, including filtering tokens by balance.Overview
A bridge interface typically needs:- A chain selector dropdown
- A token selector with search and balance display
- State management to connect selections to the quote system
Chain Selection Component
This component displays available source chains with logos and names.Copy
import { useState, useEffect } from 'react';
import { useMina } from '@siphoyawe/mina-sdk/react';
import type { Chain } from '@siphoyawe/mina-sdk/react';
interface ChainSelectorProps {
selectedChainId: number | null;
onSelect: (chain: Chain) => void;
}
export function ChainSelector({ selectedChainId, onSelect }: ChainSelectorProps) {
const { mina, isReady } = useMina();
const [chains, setChains] = useState<Chain[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
// Fetch available chains on mount
useEffect(() => {
if (!mina || !isReady) return;
async function fetchChains() {
setIsLoading(true);
try {
// Get chains with valid routes to HyperEVM
const availableChains = await mina.getChainsByRoutes();
setChains(availableChains);
} catch (error) {
console.error('Failed to fetch chains:', error);
} finally {
setIsLoading(false);
}
}
fetchChains();
}, [mina, isReady]);
// Find the selected chain object
const selectedChain = chains.find((c) => c.id === selectedChainId);
if (isLoading) {
return (
<div className="chain-selector loading">
<span>Loading chains...</span>
</div>
);
}
return (
<div className="chain-selector">
{/* Selected Chain Display / Trigger Button */}
<button
className="chain-selector-trigger"
onClick={() => setIsOpen(!isOpen)}
type="button"
>
{selectedChain ? (
<>
<img
src={selectedChain.logoUrl}
alt={selectedChain.name}
className="chain-logo"
/>
<span>{selectedChain.name}</span>
</>
) : (
<span>Select a chain</span>
)}
<ChevronDownIcon />
</button>
{/* Dropdown List */}
{isOpen && (
<div className="chain-selector-dropdown">
{chains.map((chain) => (
<button
key={chain.id}
className={`chain-option ${chain.id === selectedChainId ? 'selected' : ''}`}
onClick={() => {
onSelect(chain);
setIsOpen(false);
}}
type="button"
>
<img
src={chain.logoUrl}
alt={chain.name}
className="chain-logo"
/>
<span className="chain-name">{chain.name}</span>
<span className="chain-id">ID: {chain.id}</span>
</button>
))}
</div>
)}
</div>
);
}
// Simple chevron icon component
function ChevronDownIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="2" fill="none" />
</svg>
);
}
Token Selection Component
This component displays available tokens for a selected chain with balance information.Copy
import { useState, useEffect, useMemo } from 'react';
import { useMina, useTokenBalance } from '@siphoyawe/mina-sdk/react';
import type { Token } from '@siphoyawe/mina-sdk/react';
interface TokenSelectorProps {
chainId: number | null;
walletAddress: string | null;
selectedToken: Token | null;
onSelect: (token: Token) => void;
showOnlyWithBalance?: boolean;
}
export function TokenSelector({
chainId,
walletAddress,
selectedToken,
onSelect,
showOnlyWithBalance = false,
}: TokenSelectorProps) {
const { mina, isReady } = useMina();
const [tokens, setTokens] = useState<Token[]>([]);
const [balances, setBalances] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// Fetch tokens when chain changes
useEffect(() => {
if (!mina || !isReady || !chainId) {
setTokens([]);
return;
}
async function fetchTokens() {
setIsLoading(true);
try {
// Get bridgeable tokens (only tokens that can bridge to HyperEVM)
const bridgeableTokens = await mina.getBridgeableTokens(chainId);
setTokens(bridgeableTokens);
} catch (error) {
console.error('Failed to fetch tokens:', error);
} finally {
setIsLoading(false);
}
}
fetchTokens();
}, [mina, isReady, chainId]);
// Fetch balances for all tokens
useEffect(() => {
if (!mina || !isReady || !chainId || !walletAddress || tokens.length === 0) {
return;
}
async function fetchBalances() {
const balanceMap: Record<string, string> = {};
// Fetch balances in parallel (batch of 5 at a time)
const batchSize = 5;
for (let i = 0; i < tokens.length; i += batchSize) {
const batch = tokens.slice(i, i + batchSize);
const results = await Promise.allSettled(
batch.map((token) =>
mina.getBalance(chainId, token.address, walletAddress)
)
);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
balanceMap[batch[index].address] = result.value.formatted;
}
});
}
setBalances(balanceMap);
}
fetchBalances();
}, [mina, isReady, chainId, walletAddress, tokens]);
// Filter and sort tokens
const filteredTokens = useMemo(() => {
let filtered = tokens;
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(token) =>
token.symbol.toLowerCase().includes(query) ||
token.name.toLowerCase().includes(query)
);
}
// Filter to only tokens with balance
if (showOnlyWithBalance) {
filtered = filtered.filter((token) => {
const balance = balances[token.address];
return balance && parseFloat(balance) > 0;
});
}
// Sort by balance (tokens with balance first, then alphabetically)
return filtered.sort((a, b) => {
const balanceA = parseFloat(balances[a.address] || '0');
const balanceB = parseFloat(balances[b.address] || '0');
if (balanceA > 0 && balanceB === 0) return -1;
if (balanceA === 0 && balanceB > 0) return 1;
if (balanceA !== balanceB) return balanceB - balanceA;
return a.symbol.localeCompare(b.symbol);
});
}, [tokens, searchQuery, showOnlyWithBalance, balances]);
if (!chainId) {
return (
<div className="token-selector disabled">
<span>Select a chain first</span>
</div>
);
}
return (
<div className="token-selector">
{/* Selected Token Display */}
<button
className="token-selector-trigger"
onClick={() => setIsOpen(!isOpen)}
type="button"
disabled={isLoading}
>
{selectedToken ? (
<>
<img
src={selectedToken.logoUrl}
alt={selectedToken.symbol}
className="token-logo"
/>
<div className="token-info">
<span className="token-symbol">{selectedToken.symbol}</span>
{balances[selectedToken.address] && (
<span className="token-balance">
{balances[selectedToken.address]}
</span>
)}
</div>
</>
) : (
<span>{isLoading ? 'Loading tokens...' : 'Select token'}</span>
)}
</button>
{/* Dropdown with Search */}
{isOpen && (
<div className="token-selector-dropdown">
{/* Search Input */}
<div className="token-search">
<input
type="text"
placeholder="Search tokens..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
</div>
{/* Token List */}
<div className="token-list">
{filteredTokens.length === 0 ? (
<div className="no-tokens">
{searchQuery ? 'No tokens match your search' : 'No tokens available'}
</div>
) : (
filteredTokens.map((token) => (
<TokenOption
key={token.address}
token={token}
balance={balances[token.address]}
isSelected={selectedToken?.address === token.address}
onSelect={() => {
onSelect(token);
setIsOpen(false);
setSearchQuery('');
}}
/>
))
)}
</div>
</div>
)}
</div>
);
}
// Individual token option component
interface TokenOptionProps {
token: Token;
balance?: string;
isSelected: boolean;
onSelect: () => void;
}
function TokenOption({ token, balance, isSelected, onSelect }: TokenOptionProps) {
const hasBalance = balance && parseFloat(balance) > 0;
return (
<button
className={`token-option ${isSelected ? 'selected' : ''} ${hasBalance ? 'has-balance' : ''}`}
onClick={onSelect}
type="button"
>
<img src={token.logoUrl} alt={token.symbol} className="token-logo" />
<div className="token-details">
<span className="token-symbol">{token.symbol}</span>
<span className="token-name">{token.name}</span>
</div>
<div className="token-balance-info">
{balance ? (
<>
<span className="balance-amount">{formatBalance(balance)}</span>
{token.priceUsd && (
<span className="balance-usd">
${(parseFloat(balance) * token.priceUsd).toFixed(2)}
</span>
)}
</>
) : (
<span className="balance-loading">...</span>
)}
</div>
</button>
);
}
// Helper to format balance display
function formatBalance(balance: string): string {
const num = parseFloat(balance);
if (num === 0) return '0';
if (num < 0.0001) return '<0.0001';
if (num < 1) return num.toFixed(4);
if (num < 1000) return num.toFixed(2);
if (num < 1000000) return `${(num / 1000).toFixed(2)}K`;
return `${(num / 1000000).toFixed(2)}M`;
}
Using with State Management
Here’s how to combine these components with React state:BridgeForm.tsx
Copy
import { useState } from 'react';
import { MinaProvider } from '@siphoyawe/mina-sdk/react';
import type { Chain, Token } from '@siphoyawe/mina-sdk/react';
import { ChainSelector } from './ChainSelector';
import { TokenSelector } from './TokenSelector';
import { useAccount } from 'wagmi'; // Or your wallet library
function BridgeForm() {
const { address: walletAddress } = useAccount();
// State for selections
const [sourceChain, setSourceChain] = useState<Chain | null>(null);
const [sourceToken, setSourceToken] = useState<Token | null>(null);
const [amount, setAmount] = useState('');
// Reset token when chain changes
const handleChainChange = (chain: Chain) => {
setSourceChain(chain);
setSourceToken(null); // Clear token selection
setAmount('');
};
return (
<div className="bridge-form">
<div className="form-section">
<label>From Chain</label>
<ChainSelector
selectedChainId={sourceChain?.id ?? null}
onSelect={handleChainChange}
/>
</div>
<div className="form-section">
<label>Token</label>
<TokenSelector
chainId={sourceChain?.id ?? null}
walletAddress={walletAddress ?? null}
selectedToken={sourceToken}
onSelect={setSourceToken}
showOnlyWithBalance={false}
/>
</div>
<div className="form-section">
<label>Amount</label>
<input
type="number"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={!sourceToken}
/>
</div>
{/* Display selected values */}
{sourceChain && sourceToken && amount && (
<div className="selection-summary">
Bridging {amount} {sourceToken.symbol} from {sourceChain.name} to HyperEVM
</div>
)}
</div>
);
}
// Wrap with MinaProvider at app level
export function App() {
return (
<MinaProvider config={{ integrator: 'my-app' }}>
<BridgeForm />
</MinaProvider>
);
}
Using the useTokenBalance Hook
For individual token balance display, use theuseTokenBalance hook:
SingleTokenBalance.tsx
Copy
import { useTokenBalance } from '@siphoyawe/mina-sdk/react';
interface BalanceDisplayProps {
chainId: number;
tokenAddress: string;
walletAddress: string;
}
export function BalanceDisplay({ chainId, tokenAddress, walletAddress }: BalanceDisplayProps) {
const {
formattedBalance,
symbol,
balanceUsd,
isLoading,
error,
refetch,
} = useTokenBalance({
chainId,
tokenAddress,
walletAddress,
refetchInterval: 15000, // Refresh every 15 seconds
});
if (isLoading && !formattedBalance) {
return <span className="balance">Loading...</span>;
}
if (error) {
return (
<span className="balance error">
Error loading balance
<button onClick={refetch}>Retry</button>
</span>
);
}
return (
<div className="balance-display">
<span className="balance">
{formattedBalance ?? '0'} {symbol}
</span>
{balanceUsd !== null && (
<span className="balance-usd">${balanceUsd.toFixed(2)}</span>
)}
</div>
);
}
Best Practices
When building token selectors, consider these UX best practices:
- Show tokens with balances at the top of the list
- Include token logos for visual recognition
- Display both symbol and name for clarity
- Show USD values when available
- Add search functionality for chains with many tokens
- Disable the token selector until a chain is selected
Next Steps
Quote Display
Show quote details and fees
Full Integration
Complete bridge widget
