Skip to Content
GuidesTransaction Tracking in React

Track Transactions in React with Pulsar

This guide adds Pulsar  to a React app, step by step, for an EVM or a Solana wallet. At the end you have:

  • a transaction store from @tuwaio/pulsar-core, saved to localStorage;
  • the chain adapter from @tuwaio/pulsar-evm or @tuwaio/pulsar-solana, which tracks each transaction in the background;
  • @tuwaio/pulsar-react restarting the trackers after a page reload;
  • a button that sends a contract (EVM) or program (Solana) call and tracks it until its final status.

The guide installs the Pulsar packages one by one. The Next.js + Pulsar Packages template on Starter Templates is a complete app built the same way.

Tip

Every step has an EVM and a Solana tab. The tabs are linked: pick your network once and every step follows it.


Before You Start

  • React 19.2 or newer. The examples use the Next.js App Router: 'use client' modules and the @/ import alias for src/.
  • A wallet connection. This guide uses Nova Connect  on Satellite Connect: follow its README to render SatelliteConnectProvider, the watcher of your network and NovaConnectProvider. Pulsar does not require Nova: the EVM adapter works with any @wagmi/core config, and the Solana adapter reads the connection that Satellite Connect saves.
  • Your network configuration in one module, which the code below imports:

wagmiConfig (your @wagmi/core config) and appChains (its viem chains) exported from @/configs/wagmiConfig.

Pulsar validates the metadata of every transaction before anything else runs: each title string must be 100 characters or less, each description string 300 characters or less, and the JSON of payload 10 KB or less, without executable-like strings. Invalid metadata makes executeTxAction throw PulsarTransactionValidationError.


Step 1: Install the Packages

Install the Pulsar packages of your network and their peer dependencies:

pnpm add @tuwaio/pulsar-core @tuwaio/pulsar-evm @tuwaio/pulsar-react \ @tuwaio/orbit-core @tuwaio/orbit-evm @wagmi/core viem zustand immer dayjs

Step 2: Describe the Contract or Program

You need the address and the ABI (Application Binary Interface) of the contract you call. This guide uses a Counter contract deployed on Sepolia:

src/abis/CounterAbi.ts
// A Counter contract deployed on Sepolia. export const COUNTER_ADDRESS = '0xAe7f46914De82028eCB7E2bF97Feb3D3dDCc2BAB'; export const CounterAbi = [ { inputs: [], name: 'decrement', outputs: [], stateMutability: 'nonpayable', type: 'function', }, { inputs: [], name: 'getCurrentNumber', outputs: [ { internalType: 'uint256', name: '', type: 'uint256', }, ], stateMutability: 'view', type: 'function', }, { inputs: [], name: 'increment', outputs: [], stateMutability: 'nonpayable', type: 'function', }, ] as const;

Step 3: Create an Action

An action is a function that asks the wallet to sign and submit the transaction and returns its key. Pulsar calls it inside executeTxAction and tracks the transaction under the returned key. Keep actions in their own files to reuse them from several components.

src/transactions/actions/increment.ts
import { type Config, writeContract } from '@wagmi/core'; import { sepolia } from 'viem/chains'; import { COUNTER_ADDRESS, CounterAbi } from '@/abis/CounterAbi'; // Returns the transaction hash, which Pulsar uses as the txKey. export function increment({ wagmiConfig }: { wagmiConfig: Config }) { return writeContract(wagmiConfig, { abi: CounterAbi, address: COUNTER_ADDRESS, functionName: 'increment', chainId: sepolia.id, }); }

Optional: the same call from an ERC-4337 smart account. With @tuwaio/orbit-evm you can send the call as a UserOperation from a Solady smart account owned by the connected wallet. The action returns the userOpHash. Pulsar does not detect UserOperations on its own: Step 6 passes tracker: TransactionTracker.ERC4337 and your pimlicoApiKey. Pulsar then tracks it in two stages: the bundler until the UserOperation is included, then the bundle transaction on-chain. The ERC-4337 guide explains the lifecycle.

src/transactions/actions/incrementWithSmartAccount.ts
import { createPimlicoSmartAccountClient } from '@tuwaio/orbit-evm'; import type { Config } from '@wagmi/core'; import { encodeFunctionData } from 'viem'; import { sepolia } from 'viem/chains'; import { COUNTER_ADDRESS, CounterAbi } from '@/abis/CounterAbi'; // Returns the userOpHash, which Pulsar uses as the txKey. export async function incrementWithSmartAccount({ wagmiConfig, pimlicoApiKey, }: { wagmiConfig: Config; pimlicoApiKey?: string; }) { // A Solady smart account owned by the connected wallet, sent through the Pimlico bundler. // With an API key, gas is sponsored by the Pimlico paymaster by default. const { account, bundlerClient } = await createPimlicoSmartAccountClient({ chain: sepolia, wagmiConfig, apiKey: pimlicoApiKey, }); return bundlerClient.sendUserOperation({ account, calls: [ { to: COUNTER_ADDRESS, data: encodeFunctionData({ abi: CounterAbi, functionName: 'increment' }), }, ], }); }

Step 4: Create the Transaction Store

Create the store once, in a client module. createPulsarStore connects Pulsar to your network through its adapter and saves the transactions to localStorage under name. createBoundedUseStore turns the vanilla store into a React hook. beforeTxProcess is an optional check that runs before the wallet is asked to sign: throw to block the transaction.

src/hooks/txTrackingHooks.ts
'use client'; import { createBoundedUseStore, createPulsarStore, type EvmTransaction } from '@tuwaio/pulsar-core'; import { pulsarEvmAdapter } from '@tuwaio/pulsar-evm'; import { appChains, wagmiConfig } from '@/configs/wagmiConfig'; // Typed transactions of your app type IncrementTx = EvmTransaction & { type: 'increment'; payload: { value: number; }; }; export type TransactionUnion = IncrementTx; export const pulsarStore = createPulsarStore<TransactionUnion>({ name: 'transactions-tracking-storage', // localStorage key adapter: pulsarEvmAdapter(wagmiConfig, appChains), beforeTxProcess: () => { if (!navigator.onLine) throw new Error('You are offline.'); }, }); export const usePulsarStore = createBoundedUseStore(pulsarStore);

An app with EVM and Solana wallets passes both adapters in an array: adapter: [pulsarEvmAdapter(wagmiConfig, appChains), pulsarSolanaAdapter({ rpcUrls: solanaRPCUrls })].


Step 5: Resume Tracking After a Reload

The store restores its transactions from localStorage, but their trackers are not running after a reload. useInitializeTransactionsPool from @tuwaio/pulsar-react starts them again for every pending transaction. Each call starts new trackers, so render this component only once, in a component that stays mounted (for example next to your providers in the root layout):

src/components/PulsarInitializer.tsx
'use client'; import { useInitializeTransactionsPool } from '@tuwaio/pulsar-react'; import { usePulsarStore } from '@/hooks/txTrackingHooks'; export function PulsarInitializer() { const initializeTransactionsPool = usePulsarStore((state) => state.initializeTransactionsPool); // Restarts the trackers of transactions that were pending before a page reload. useInitializeTransactionsPool({ initializeTransactionsPool }); return null; }

Step 6: Send and Track the Transaction

Call executeTxAction with the action and the metadata of the transaction. Pulsar validates the title, description and payload, checks the network of the wallet, runs beforeTxProcess, calls the action, adds the transaction to the pool and starts its tracker. From then on the store updates the status on its own: components read it with selectors, so it stays correct after navigation or a reload. A beforeTxProcess passed to executeTxAction replaces the one of the store for that transaction.

Pulsar asks the wallet to switch to desiredChainID when needed. For standard EVM transactions the promise of executeTxAction resolves only after tracking has finished, so render the status from the store rather than from the promise.

src/components/Increment.tsx
'use client'; import { ConnectButton } from '@tuwaio/nova-connect/components'; import { OrbitAdapter } from '@tuwaio/orbit-core'; import { TransactionTracker } from '@tuwaio/pulsar-core'; import { sepolia } from 'viem/chains'; import { wagmiConfig } from '@/configs/wagmiConfig'; import { usePulsarStore } from '@/hooks/txTrackingHooks'; import { increment } from '@/transactions/actions/increment'; import { incrementWithSmartAccount } from '@/transactions/actions/incrementWithSmartAccount'; const pimlicoApiKey = process.env.NEXT_PUBLIC_PIMLICO_API_KEY; export const Increment = () => { const executeTxAction = usePulsarStore((state) => state.executeTxAction); const pendingCount = usePulsarStore( (state) => Object.values(state.transactionsPool).filter((tx) => tx.pending).length, ); const handleIncrement = async () => { try { await executeTxAction({ actionFunction: () => increment({ wagmiConfig }), params: { type: 'increment', adapter: OrbitAdapter.EVM, desiredChainID: sepolia.id, // the wallet is asked to switch to Sepolia if needed title: ['Incrementing', 'Incremented', 'Increment failed', 'Increment replaced'], description: 'Increment the counter by 1.', payload: { value: 1 }, withTrackedModal: true, // opens the tracking modal of Nova Transactions }, onSuccess: (tx) => console.log('Incremented in', tx.hash), }); } catch (error) { // Rejected signature, declined network switch, failed preflight... Also saved in `initialTx.error`. console.error(error); } }; const handleIncrementWithSmartAccount = async () => { try { await executeTxAction({ actionFunction: () => incrementWithSmartAccount({ wagmiConfig, pimlicoApiKey }), params: { type: 'increment', adapter: OrbitAdapter.EVM, desiredChainID: sepolia.id, title: 'Increment with a smart account', payload: { value: 1 }, tracker: TransactionTracker.ERC4337, // required: UserOperations are not detected automatically pimlicoApiKey, // saved with the transaction to resume tracking after a reload }, }); } catch (error) { console.error(error); } }; return ( <div className="flex flex-col items-start gap-4"> <ConnectButton /> <button type="button" onClick={handleIncrement}> Increment </button> <button type="button" onClick={handleIncrementWithSmartAccount}> Increment with a smart account </button> {pendingCount > 0 && <p>{pendingCount} pending transaction(s)</p>} </div> ); };

Next Steps

Last updated on