Skip to content

Global transaction UX

One of DappQL's most useful conveniences: every mutation in your app emits lifecycle events to a single provider-level callback. Wire toasts, analytics, Sentry, Segment, or any other side effect once, and every useMutation in every component automatically triggers it.

No prop-drilling. No per-hook onSuccess / onError glue. One function.

Wiring it up

tsx
import { DappQLProvider } from '@dappql/react'
import { toast } from 'your-toast-library'

<DappQLProvider
  onMutationUpdate={({ status, contractName, functionName, transactionName, txHash, error, args, account, address }) => {
    if (status === 'submitted') toast.info(`Submitting ${transactionName || functionName}…`)
    if (status === 'signed')    toast.info(`${transactionName || functionName} signed`, { txHash })
    if (status === 'success')   toast.success(`${transactionName || functionName} confirmed`)
    if (status === 'error')     toast.error(error?.message ?? 'Transaction failed')
  }}
>
  {children}
</DappQLProvider>

The MutationInfo shape

Every event fires with the same object:

ts
type MutationInfo = {
  id: string                    // unique per send(), stable across the lifecycle
  status: 'submitted' | 'signed' | 'success' | 'error'
  account?: Address             // signer
  address: Address              // contract address
  contractName: string          // e.g. "Token"
  functionName: string          // e.g. "transfer"
  transactionName?: string      // whatever you passed to useMutation(config, name)
  txHash?: Address              // hash, available from 'signed' onward
  args?: readonly unknown[]     // what the user called send() with
  error?: Error                 // populated on 'error'
  receipt?: TransactionReceipt  // populated on 'success'
}

Use id to correlate events across the same transaction, the same id fires on submitted, then signed (with txHash), then either success (with receipt) or error.

Lifecycle

┌────────────┐    sign fails   ┌────────┐
│ submitted  │────────────────▶│ error  │
└──────┬─────┘                 └────────┘
       │ signed

┌────────────┐   mining fails   ┌────────┐
│   signed   │─────────────────▶│ error  │
└──────┬─────┘                  └────────┘
       │ mined

┌────────────┐
│  success   │
└────────────┘

Every send produces either submitted → signed → success (happy path) or submitted → error / signed → error (failure).

Patterns

tsx
const toastOptions = {
  action: (txHash: string) => ({
    label: 'View',
    onClick: () => window.open(`https://basescan.org/tx/${txHash}`),
  }),
}

onMutationUpdate={({ status, transactionName, txHash, error }) => {
  if (status === 'signed' && txHash) {
    toast.info(`${transactionName} sent`, { action: toastOptions.action(txHash) })
  }
  if (status === 'success' && txHash) {
    toast.success(`${transactionName} confirmed`, { action: toastOptions.action(txHash) })
  }
  if (status === 'error') toast.error(error?.message ?? 'Failed')
}}

Sentry breadcrumbs + error capture

tsx
import * as Sentry from '@sentry/browser'

onMutationUpdate={(info) => {
  Sentry.addBreadcrumb({
    category: 'mutation',
    message: `${info.contractName}.${info.functionName} → ${info.status}`,
    data: { txHash: info.txHash, args: info.args?.map(String) },
  })
  if (info.status === 'error' && info.error) {
    Sentry.captureException(info.error, {
      tags: {
        contract: info.contractName,
        function: info.functionName,
      },
    })
  }
}}

Analytics

tsx
import { track } from '@segment/analytics-next'

onMutationUpdate={({ status, contractName, functionName, transactionName, account, txHash }) => {
  if (status === 'submitted') {
    track('tx_submitted', { contract: contractName, function: functionName, transactionName, account })
  }
  if (status === 'success') {
    track('tx_confirmed', { contract: contractName, function: functionName, txHash, account })
  }
  if (status === 'error') {
    track('tx_failed', { contract: contractName, function: functionName, account })
  }
}}

Persisting a "pending transactions" tray

tsx
const [pending, setPending] = useState<MutationInfo[]>([])

<DappQLProvider
  onMutationUpdate={(info) => {
    setPending((prev) => {
      if (info.status === 'submitted' || info.status === 'signed') {
        const idx = prev.findIndex((p) => p.id === info.id)
        return idx === -1 ? [...prev, info] : prev.map((p) => (p.id === info.id ? info : p))
      }
      // success / error, remove from pending
      return prev.filter((p) => p.id !== info.id)
    })
  }}
>

A growing tray of in-flight transactions for the UI shell, kept in sync with every useMutation in the app.

transactionName matters

Pass meaningful names to useMutation, they're what appears in the global callback and the UI:

tsx
// Generic, boring
useMutation(Token.mutation.transfer)
// onMutationUpdate sees: functionName: 'transfer', transactionName: undefined

// Named, speakable
useMutation(Token.mutation.transfer, 'Send USDC')
// onMutationUpdate sees: transactionName: 'Send USDC'

'Send USDC' reads better in a toast than Token.transfer.

Interaction with per-call options

onMutationUpdate fires for every mutation, regardless of whether individual useMutation hooks override simulate, address, etc. The callback is global.

If you want per-hook side effects, layer them on top of the provider callback, the per-hook state (mutation.isSuccess, etc.) is still available in your component.

Server-rendered / no-window contexts

onMutationUpdate only fires on client-side mutations (writes require a wallet). On the server, no mutations happen, so the callback is never invoked. Safe to pass a callback that uses browser-only APIs, just handle the undefined window case if needed:

tsx
onMutationUpdate={(info) => {
  if (typeof window === 'undefined') return
  // ... browser-only logic
}}