Why Web3 Transaction State Management is Broken (And Why useState is an Anti-Pattern)
Most Web3 frontends treat blockchain interactions as standard HTTP POST requests. They fire an RPC call, slap a loading spinner onto a local React component state, and pray the user doesn’t touch anything until a transaction hash returns.
// ⚠️ THE FATAL ANTI-PATTERN: Component-bound transaction state
export function SwapButton() {
const [isLoading, setIsLoading] = useState(false);
const [txHash, setTxHash] = useState<string | null>(null);
const handleDeposit = async () => {
try {
setIsLoading(true);
// Step 1: Wallet signature & broadcast
const hash = await walletClient.sendTransaction({ ... });
setTxHash(hash);
// Step 2: Awaiting on-chain receipt in component memory
await publicClient.waitForTransactionReceipt({ hash });
toast.success('Transaction confirmed!');
} catch (error) {
console.error('Transaction failed:', error);
toast.error('Transaction reverted');
} finally {
setIsLoading(false);
}
};
return (
<button onClick={handleDeposit} disabled={isLoading}>
{isLoading ? 'Processing...' : 'Deposit Funds'}
</button>
);
}This pattern is fundamentally flawed. It collapses the moment it encounters real-world distributed blockchain mechanics: mempool congestion, fee spikes, dropped nonces, wallet speed-ups, tab switching, and route transitions.
1. The Distributed State Mismatch
In classical Web2 client-server architectures, a request follows a synchronous, binary lifecycle:
Pending ──► Resolved (200 OK) or Rejected (500 Error)A blockchain transaction is not an HTTP request. It is an asynchronous, multi-phase state transition submitted to a decentralized, adversarial peer-to-peer network:
When your frontend models this complex lifecycle as a boolean isLoading, your application breaks across multiple critical failure modes.
2. Four Production Failure Modes of useState
Failure Mode 1: The Component Unmount Bug
When a user initiates a transaction (e.g., swapping tokens or staking collateral) and then navigates to /portfolio or closes a modal sheet, React unmounts the originating component.
- The local
useStatevariables (isLoading,txHash) are destroyed. - The asynchronous promise chain (
waitForTransactionReceipt) is garbage-collected or becomes an unhandled promise rejection. - The UI loses all awareness of the pending transaction.
- When the user navigates back, the button is reset to idle. The user believes their transaction vanished or failed, prompting them to submit a duplicate transaction that results in wasted gas or inadvertent double-spending.
Failure Mode 2: The Mempool Replacement Trap (Speed-Ups and Cancels)
When Ethereum or an EVM rollup experiences gas volatility, a transaction can sit pending in the mempool for minutes. Users routinely open MetaMask, Rabby, or Coinbase Wallet and click “Speed Up” or “Cancel”.
Here is what happens at the network layer:
- The wallet generates a new transaction
T2with the same account nonceNas the original transactionT1. T2includes a>= 10%bump inmaxPriorityFeePerGas/maxFeePerGas.- The network assigns
T2a completely different transaction hash:
Hash(T2) ≠ Hash(T1) (Account Nonce N is identical)In naive component code, developers simply await the receipt of the initial hash:
// Naive component handler:
const receipt = await publicClient.waitForTransactionReceipt({ hash: H1 });Although modern lower-level libraries like viem provide internal replacement detection (via block scanning and the onReplaced callback), a naive component relying on local useState breaks down in production:
- State Desynchronization: The component’s local
hashstate remains bound to the superseded $H_1$. If the UI renders an explorer link (etherscan.io/tx/{hash}), it directs users to an evicted transaction that returns a 404 error. - Component Isolation: Even if an inline
onReplacedcallback intercepts $H_2$, an isolated button component cannot propagate the replacement hash to global navigation bars, activity drawers, or notification feeds. - Premature Error Rejection: If the wallet user submits a cancellation or the RPC poll times out before the replacement is mined, the promise rejects with an unhandled exception—displaying a failure modal even though the replacement transaction $T_2$ was mined successfully.
Pulsar solves this by intercepting Viem’s onReplaced event at the adapter level, updating the transaction record in the central persistent store (status = TransactionStatus.Replaced, replacedTxHash = H2, pending: false), and emitting the replacement hash via the onReplaced callback for application-level handling.
Failure Mode 3: Cross-Tab & Browser Reload Amnesia
Transactions take anywhere from 12 seconds (Ethereum) to several minutes under network congestion. Users frequently:
- Open a second tab to continue browsing your dApp.
- Accidentally reload the page.
- Switch mobile apps while waiting for wallet signing.
Component state lives exclusively in ephemeral RAM. A single refresh obliterates all in-flight tracking state. The transaction is still active in the mempool, but your frontend has forgotten it entirely, and any code chained to its promise, such as refetching balances, never runs.
Failure Mode 4: Soft Confirmations vs. Finality
On high-throughput chains like Solana or optimistic rollups (Arbitrum, Base), a transaction passes through distinct consensus stages:
| Stage | Solana Status | EVM Rollup Status | Safe to Assume Complete? |
|---|---|---|---|
| Optimistic | processed | L2 Sequencer Receipt | ⚠️ Re-org possible |
| Consensus Safe | confirmed | L1 Batch Submission | ✅ High certainty |
| Immutable | finalized | L1 Finalized State | 🔒 Mathematically irreversible |
A boolean isLoading cannot represent these transitions, resulting in premature cache invalidation and race conditions where frontend queries fetch stale blockchain state.
3. The Solution: Decoupled, Local-First State Machines
To build resilient Web3 frontends, transaction tracking must never live in React components. It must exist in an independent, headless state machine that operates outside the DOM tree:
┌─────────────────────────────────────────────────────────────┐
│ Blockchain Transports Layer │
│ Viem (EVM) • @solana/kit (Solana) • Bundlers (ERC-4337) │
└──────────────────────────────┬──────────────────────────────┘
│ (Dispatched Actions)
▼
┌──────────────────────────────┴──────────────────────────────┐
│ Headless Engine (@tuwaio/pulsar-core) │
│ • Local-first Indexed/LocalStorage Persistence (Immer) │
│ • Nonce-aware Mempool Replacement Watcher (onReplaced) │
│ • Auto-reconnection & Polling Resumption after Reload │
└──────────────────────────────┬──────────────────────────────┘
│ (Passive Subscriptions)
▼
┌──────────────────────────────┴──────────────────────────────┐
│ React View Layer (Passive) │
│ Subscribes to selectors: usePulsarStore(...) │
└─────────────────────────────────────────────────────────────┘A production-grade Web3 transaction architecture enforces four non-negotiable rules:
- Persistent Store as Single Source of Truth: Broadcasted transactions must be synchronously serialized and saved to persistent storage (
localStorage/IndexedDB) before awaiting network confirmation. - Nonce-Aware Replacement Detection: The tracker detects when a transaction with hash
H1is replaced in the mempool (e.g., speed-up or cancel via identical nonce). The store transitionsH1toTransactionStatus.Replaced, recordsreplacedTxHash(H2), setspending: false, and fires theonReplaced(newTx, oldTx)callback. Pulsar stops tracking the superseded record and hands off the replacement hash to the consuming application layer. - Resilient Session Resumption: When a user refreshes or reopens the application, a bootstrap routine scans the pool and concurrently resumes polling for all transactions still marked as
pending: true. - Passive UI Binding: Components do not manage transaction promises. They merely read state slices via selectors (
selectPendingTransactions,selectTxByKey).
4. Implementation in the TUWA Ecosystem
The TUWA stack implements this architecture via Pulsar (@tuwaio/pulsar-core, @tuwaio/pulsar-evm, @tuwaio/pulsar-solana).
Step 1: Define Strongly-Typed Transaction Unions
Transactions carry custom business payloads (e.g., token amounts, recipient addresses) alongside protocol metadata:
// src/types/transactions.ts
import type { EvmTransaction, SolanaTransaction } from '@tuwaio/sdk/pulsar';
export enum AppTxType {
TOKEN_SWAP = 'TOKEN_SWAP',
STAKE_COLLATERAL = 'STAKE_COLLATERAL',
}
export type TokenSwapTx = EvmTransaction & {
type: AppTxType.TOKEN_SWAP;
payload: {
tokenIn: string;
tokenOut: string;
amount: string;
};
};
export type TransactionUnion = TokenSwapTx | SolanaTransaction;Step 2: Initialize the Persistent Pulsar Store
Instantiate the headless store with multi-chain adapters and local-first persistence:
// src/stores/pulsarStore.ts
'use client';
import { pulsarEvmAdapter } from '@tuwaio/evm-sdk/pulsar';
import { preFlightTxCheck } from '@tuwaio/quasar-sdk/react';
import { createBoundedUseStore, createPulsarStore } from '@tuwaio/sdk/pulsar';
import { pulsarSolanaAdapter } from '@tuwaio/solana-sdk/pulsar';
import { syncTransaction } from '@/app/actions';
import { wagmiConfig, appChains, solanaRPCUrls } from '@/configs/web3';
import { QUASAR_BASE_URL } from '@/constants';
import type { TransactionUnion } from '@/types/transactions';
const initialStore = createPulsarStore<TransactionUnion>({
name: 'tuwa-transactions-storage',
adapter: [pulsarEvmAdapter(wagmiConfig, appChains), pulsarSolanaAdapter({ rpcUrls: solanaRPCUrls })],
// Pre-flight: verify the SIWX session and that the Quasar engine (Cloud or self-hosted) is reachable
beforeTxProcess: async () => {
await preFlightTxCheck(QUASAR_BASE_URL);
},
// Background sync: delegate to a Server Action that verifies SIWX session
// server-side and forwards the transaction to Quasar Engine via Secret Key
// Throw on failure: Pulsar then keeps the transaction marked as unsynced and sends it again later
onRemoteCreate: async (tx) => {
const result = await syncTransaction(tx);
if (!result.success) throw new Error(result.reason ?? 'Remote sync failed');
},
});
export const usePulsarStore = createBoundedUseStore(initialStore);The syncTransaction Server Action handles SIWX session verification and Quasar SDK calls server-side:
// src/app/actions.ts
'use server';
import { Quasar, Transaction } from '@tuwaio/quasar-sdk';
import { getSiwxServerSession, isSessionMatchingTarget } from '@tuwaio/sdk/siwx/server';
import { cookies } from 'next/headers';
import { QUASAR_BASE_URL } from '@/constants';
const quasar = new Quasar({
baseUrl: QUASAR_BASE_URL,
secretKey: process.env.QUASAR_SECRET_KEY ?? '',
});
export async function syncTransaction(tx: Transaction) {
const session = await getSiwxServerSession({
cookieSource: await cookies(),
cookieName: 'siwx-session',
signingSecret: process.env.SIWX_SIGNING_SECRET!,
});
if (!session) {
return { success: false, reason: 'unauthenticated' };
}
// Verify the SIWX session wallet matches the transaction sender
if (tx.from && !isSessionMatchingTarget(session, tx.from, tx.chainId)) {
return { success: false, reason: 'session_mismatch' };
}
await quasar.pulsar.syncCreate(tx, 'my-dapp');
return { success: true };
}Cloud or self-hosted: QUASAR_BASE_URL (read from NEXT_PUBLIC_QUASAR_BASE_URL) is the only setting that decides which Quasar you talk to. Leave it at Quasar Cloud (https://api.tuwa.io), or point it at your own Quasar Community Edition node (for example https://api.your-domain.com) and use a secret key created in that node’s admin panel. Pulsar sync, history, and webhooks work the same way.
Step 3: Dispatching Write Actions via executeTxAction
Instead of embedding arbitrary wallet calls inside a component closure, extract your transaction dispatch into a clean @wagmi/core action and pass it to executeTxAction:
// src/transactions/evm/swapTokens.ts
import { writeContract } from '@wagmi/core';
import { parseUnits } from 'viem';
import { wagmiConfig } from '@/configs/wagmi';
import { ROUTER_ABI, ROUTER_ADDRESS } from '@/contracts/router';
export async function swapTokens({
tokenIn,
tokenOut,
amount,
recipient,
}: {
tokenIn: `0x${string}`;
tokenOut: `0x${string}`;
amount: string;
recipient: `0x${string}`;
}) {
return writeContract(wagmiConfig, {
abi: ROUTER_ABI,
address: ROUTER_ADDRESS,
functionName: 'exactInputSingle',
args: [
{
tokenIn,
tokenOut,
fee: 3000,
recipient,
amountIn: parseUnits(amount, 6),
amountOutMinimum: 0n,
sqrtPriceLimitX96: 0n,
},
],
chainId: 1,
});
}// src/components/SwapActionButton.tsx
'use client';
import { usePulsarStore } from '@/stores/pulsarStore';
import { OrbitAdapter } from '@tuwaio/sdk/orbit';
import { AppTxType } from '@/types/transactions';
import { swapTokens } from '@/transactions/evm/swapTokens';
export function SwapActionButton({
amount,
tokenIn,
tokenOut,
recipient,
}: {
amount: string;
tokenIn: `0x${string}`;
tokenOut: `0x${string}`;
recipient: `0x${string}`;
}) {
const executeTxAction = usePulsarStore((state) => state.executeTxAction);
const handleSwap = async () => {
await executeTxAction({
actionFunction: () => swapTokens({ tokenIn, tokenOut, amount, recipient }),
params: {
type: AppTxType.TOKEN_SWAP,
adapter: OrbitAdapter.EVM,
desiredChainID: 1, // Automatic chain validation & switching
title: ['Swapping Tokens', 'Swap Confirmed', 'Swap Failed', 'Transaction Sped Up'],
description: [
`Swapping ${amount} USDC for ETH...`,
`Successfully swapped ${amount} USDC for ETH.`,
'Transaction reverted on-chain.',
'Gas fee was increased; replacement hash emitted via callback.',
],
payload: {
tokenIn,
tokenOut,
amount,
},
withTrackedModal: true, // Triggers Nova UIKit modal sheet
requiredConfirmations: 2, // Wait for safe consensus depth
},
onSuccess: (tx) => {
console.log('[Pulsar] Transaction finalized:', tx.txKey);
},
onReplaced: (newTx, oldTx) => {
// Pulsar flags oldTx as Replaced (pending: false), persists replacedTxHash, and fires callback
console.warn(`[Pulsar] Transaction ${oldTx.txKey} was replaced by: ${newTx.replacedTxHash}`);
},
onError: (err, tx) => {
console.error('[Pulsar] Execution error:', err);
},
});
};
return (
<button onClick={handleSwap} className="btn-primary">
Execute Swap
</button>
);
}Step 4: Resuming Active Trackers on Page Reload
When the user reloads the application or opens a new tab, invoke initializeTransactionsPool once in your layout. Pulsar scans the persisted store and seamlessly resumes polling all pending transactions:
// src/app/providers.tsx
'use client';
import { useEffect } from 'react';
import { usePulsarStore } from '@/stores/pulsarStore';
export function TransactionTrackerBootstrap({ children }: { children: React.ReactNode }) {
const initializeTransactionsPool = usePulsarStore((s) => s.initializeTransactionsPool);
useEffect(() => {
// Automatically re-attaches trackers to all unconfirmed mempool transactions
initializeTransactionsPool();
}, [initializeTransactionsPool]);
return <>{children}</>;
}Step 5: Passive UI Subscriptions via Selectors
Components simply select active or pending transactions from the pool. If the component unmounts and remounts, it immediately displays the true, up-to-date state from the store:
// src/components/PendingTransactionsBadge.tsx
import { usePulsarStore } from '@/stores/pulsarStore';
import { selectPendingTransactions } from '@tuwaio/sdk/pulsar';
export function PendingTransactionsBadge() {
const pendingTransactions = usePulsarStore((state) => selectPendingTransactions(state.transactionsPool));
if (pendingTransactions.length === 0) return null;
return (
<div className="flex items-center gap-2 px-3 py-1 bg-amber-500/10 text-amber-500 rounded-full border border-amber-500/20 text-xs font-mono">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-ping" />
<span>{pendingTransactions.length} pending on-chain</span>
</div>
);
}Step 6: Reactions That Survive a Reload
The callbacks of executeTxAction cannot be saved to localStorage, so a transaction resumed in Step 4 finishes without them. Refetch the data it changes in the “transaction finished” event of the store: it fires once for every transaction that reaches Success, Failed or Replaced, whatever finished it. useTxFinished listens to it while a component is mounted; reactions outside React go into the onTxFinished option of createPulsarStore.
// src/components/BalanceRefresher.tsx
'use client';
import { TransactionStatus, useTxFinished } from '@tuwaio/sdk/pulsar';
import { usePulsarStore } from '@/stores/pulsarStore';
import { AppTxType } from '@/types/transactions';
export function BalanceRefresher({ refetchBalances }: { refetchBalances: () => void }) {
const subscribeTxFinished = usePulsarStore((state) => state.subscribeTxFinished);
// Also runs for a swap whose tracker was restarted after a reload
useTxFinished({
subscribeTxFinished,
onFinished: (tx) => {
if (tx.type === AppTxType.TOKEN_SWAP && tx.status === TransactionStatus.Success) refetchBalances();
},
});
return null;
}5. Architectural Comparison: useState vs. Pulsar
| Scenario | Naive useState Pattern | TUWA Pulsar |
|---|---|---|
| Route Navigation | ❌ Component unmounts; tracking promise destroyed. | ✅ Outlives DOM; updates persist in central store. |
| User Speeds Up / Cancels | ❌ Naive promise rejects or desyncs from replaced hash. | ✅ Intercepts Viem onReplaced; marks Replaced, persists replacedTxHash, and emits callback. |
| Browser Refresh | ❌ In-flight state obliterated; reset to idle. | ✅ Resumes tracking pending pool automatically upon mount. |
| Reaction After a Refresh | ❌ Code chained to the lost promise never runs; balances stay stale. | ✅ onTxFinished and useTxFinished run once for every finished transaction, also after a reload. |
| Multi-Tab Consistency | ❌ Tabs are completely desynchronized. | ✅ Storage event synchronization reflects states across tabs. |
| Multi-Chain Support | ❌ Ad-hoc Viem/Solana hook spaghetti. | ✅ Unified interface across EVM and Solana clusters. |
| Re-Org & Revert Handling | ❌ Throws raw unformatted error into component. | ✅ Structured TransactionStatus with normalized error objects. |
| Soft Confirmations (Solana) | ❌ One boolean: done at confirmed, or a long wait for finalized. | ✅ confirmationStatus shows confirmed while the transaction stays pending; Success at finalized. |
| Dropped Transaction (Solana) | ❌ Pending until an arbitrary timeout. | ✅ Fails when the blockhash expires (lastValidBlockHeight), with the reason in error. |
Conclusion
Treating Web3 transactions as ephemeral asynchronous promises is technical debt by default.
By decoupling execution from the React component tree, enforcing local-first persistence, and actively tracking mempool nonces, applications built on the TUWA ecosystem achieve deterministic reliability across arbitrary network conditions and user behaviors.
Next Steps
- Inspect the Complete Integration Standard for full architectural guidelines.
- Explore the Pulsar documentation for low-level tracker APIs.
- Learn about the Nova Transactions UI Kit to render automated toasts and modal sheets.