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 tolocalStorage; - the chain adapter from
@tuwaio/pulsar-evmor@tuwaio/pulsar-solana, which tracks each transaction in the background; @tuwaio/pulsar-reactrestarting 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.
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 forsrc/. - A wallet connection. This guide uses Nova Connect on Satellite Connect: follow its README to render
SatelliteConnectProvider, the watcher of your network andNovaConnectProvider. Pulsar does not require Nova: the EVM adapter works with any@wagmi/coreconfig, and the Solana adapter reads the connection that Satellite Connect saves. - Your network configuration in one module, which the code below imports:
EVM
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:
EVM
pnpm add @tuwaio/pulsar-core @tuwaio/pulsar-evm @tuwaio/pulsar-react \
@tuwaio/orbit-core @tuwaio/orbit-evm @wagmi/core viem zustand immer dayjsStep 2: Describe the Contract or Program
EVM
You need the address and the ABI (Application Binary Interface) of the contract you call. This guide uses a Counter contract deployed on Sepolia:
// 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.
EVM
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.
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.
EVM
'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):
'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.
EVM
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.
'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.
'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
- Show the transactions.
withTrackedModal: trueonly has an effect with Nova Transactions : renderNovaTransactionsProviderwith the state of your Pulsar store to get the tracking modal, toasts and the transaction history. - Track Safe multisig transactions with the Safe tracker of
@tuwaio/pulsar-evm: Nova shows the owner confirmations asx / y, and the@tuwaio/pulsar-evm/safeentry has the tracker without wagmi. Seepulsar-evm. - Track without the store, in your own state or on a server: EVM Trackers Standalone and Solana Trackers Standalone .
- Look up an API in the Pulsar package reference:
pulsar-core,pulsar-evm,pulsar-solana,pulsar-react. - Understand the failure modes that Pulsar handles: Why Web3 Transaction State is Broken.