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;
  • a component that refreshes its data when the transaction finishes, also after a reload.

The guide installs the Pulsar packages one by one. The nextjs-tuwa-not-sdk 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, one tracker per transaction: a later call starts none for a transaction already tracked. Render it 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. The onSuccess, onError and onReplaced callbacks of the call run only in the page session that sent it; refresh your data in the event of Step 7.

Pulsar asks the wallet to switch to desiredChainID when needed. The promise of executeTxAction resolves once the transaction is sent and its tracker has started, not when it has a result, so render the status from the store rather than from the promise. A transaction sped up in the wallet goes on under a new record, which the store follows to its result.

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> ); };

Step 7: React to Finished Transactions

Functions cannot be saved to localStorage, so a transaction still pending at a reload finishes without the callbacks of its executeTxAction. Refresh the data a transaction changes (a balance, a list, the counter) in the “transaction finished” event of the store instead. It fires once for every transaction that reaches Success, Failed or Replaced, whatever finished it: the tracker started by executeTxAction, one restarted by useInitializeTransactionsPool after a reload, or the history of Quasar.

useTxFinished from @tuwaio/pulsar-react listens to it while a component is mounted. Use it in the component that owns the data, and decide what to do from the transaction itself: one listener serves every transaction of the app, so check its type and read what you need from its payload.

src/components/CounterValue.tsx
'use client'; import { TransactionStatus } from '@tuwaio/pulsar-core'; import { useTxFinished } from '@tuwaio/pulsar-react'; import { usePulsarStore } from '@/hooks/txTrackingHooks'; export function CounterValue({ value, refetch }: { value: number | undefined; refetch: () => void }) { const subscribeTxFinished = usePulsarStore((state) => state.subscribeTxFinished); useTxFinished({ subscribeTxFinished, onFinished: (tx) => { if (tx.type === 'increment' && tx.status === TransactionStatus.Success) refetch(); }, }); return <p>Counter: {value ?? '…'}</p>; }

Reactions that need no component, such as analytics or a cache that lives outside React, go into the onTxFinished option of createPulsarStore in Step 4; it takes the same function. Keep onSuccess, onError and onReplaced for the interface of the page that sent the transaction, such as closing a form; for the same transaction, the event runs first. The differences are listed in Reacting to finished transactions  of the pulsar-core reference.


Next Steps

Last updated on