intermediate 120 min

Build with React.js & XRPL

Full-stack dApp development: account creation UI, XRP transfers, trustlines, and NFT operations in a React application.

Prerequisites

Complete these before starting this module:

What you'll learn

  • Wire xrpl.js into a React app with a single shared client and a connection-aware hook.
  • Build a wallet-connect flow that activates a testnet account and renders its live XRP balance.
  • Submit a Payment from a React form and surface the tesSUCCESS result and tx hash in the UI.
  • Read on-ledger state (account_nfts) and render an NFT gallery for any address.
  • Reason about where signing keys live and why a real dApp pushes them out of the browser.
Complete this module by peer review. Jump to assessment

Overview

So far you've talked to the XRP Ledger from a script — run the file, read the logs, done. A dApp is different: the ledger has to live behind a UI, where a human clicks a button and expects something to happen. In this module you wrap the same xrpl.js calls you already know inside a React app, so connecting a wallet, sending XRP, and browsing NFTs all become things a person does on screen rather than lines in a terminal.

The shape of the work changes when you put a UI in front of the ledger. A script runs top to bottom and exits; a React app is long-lived and reactive — it holds a connection open, re-renders when balances change, and has to cope with the fact that a payment takes a few seconds to validate while the user stares at a button. The ledger calls are identical to your scripts. What's new is where they live: in hooks, in event handlers, in component state.

Everything here runs against the testnet, so you can fund wallets from the faucet and break things freely. We'll build the core pieces inline — a shared client, a connection hook, wallet-connect, an XRP payment form, and an NFT gallery — which map onto the six lessons of the Build with the XRPL and React.js course: account setup, sending XRP, tokens and trustlines, and minting, transferring, and brokering NFTs.

One client for the whole app

The first instinct — new xrpl.Client(...) inside a component — is a trap. Components re-render constantly, and each render would open a fresh WebSocket. You want one connection for the life of the app. Create it once in a module so every import shares the same instance:

// src/lib/xrpl.ts
import * as xrpl from 'xrpl'

export const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233')

let connecting: Promise<void> | null = null

// Safe to call from anywhere — connects once, then no-ops.
export async function ensureConnected(): Promise<void> {
  if (client.isConnected()) return
  if (!connecting) connecting = client.connect()
  await connecting
}

The connecting guard matters: in React, two components can call ensureConnected() on the same render pass. Without the shared promise you'd fire two connect() calls; with it, the second caller just awaits the first.

A connection hook

Now expose that connection to components in the React way — a hook that tells you whether the socket is live, so your UI can show a spinner instead of crashing on a call that isn't ready yet.

// src/hooks/useXrpl.ts
import { useEffect, useState } from 'react'
import { client, ensureConnected } from '../lib/xrpl'

export function useXrpl() {
  const [ready, setReady] = useState(client.isConnected())

  useEffect(() => {
    let active = true
    ensureConnected().then(() => active && setReady(true))
    return () => { active = false }
  }, [])

  return { client, ready }
}

The active flag is standard React hygiene: if the component unmounts before connect() resolves, we skip the setState so you don't get a warning about updating an unmounted component.

Connect a wallet, show a balance

A "wallet" in this app is just an xrpl.js Wallet object held in state. For testnet we let the faucet create and fund it; in a real app the user would bring their own keys. Once we have an address, we read its balance — and, crucially, we keep reading it whenever the ledger closes, so the number on screen stays true after a payment.

// src/components/WalletPanel.tsx
import { useEffect, useState } from 'react'
import * as xrpl from 'xrpl'
import { useXrpl } from '../hooks/useXrpl'

export function WalletPanel() {
  const { client, ready } = useXrpl()
  const [wallet, setWallet] = useState<xrpl.Wallet | null>(null)
  const [balance, setBalance] = useState<string>('—')

  async function connect() {
    const { wallet } = await client.fundWallet()
    setWallet(wallet)
  }

  // Refresh the balance now, and again on every validated ledger.
  useEffect(() => {
    if (!ready || !wallet) return

    const refresh = async () => {
      try {
        setBalance(await client.getXrpBalance(wallet.address))
      } catch {
        setBalance('0') // account not activated yet
      }
    }

    refresh()
    client.on('ledgerClosed', refresh)
    return () => { client.off('ledgerClosed', refresh) }
  }, [ready, wallet, client])

  if (!wallet) {
    return <button disabled={!ready} onClick={connect}>Connect testnet wallet</button>
  }

  return (
    <div>
      <code>{wallet.address}</code>
      <p>{balance} XRP</p>
    </div>
  )
}

That ledgerClosed subscription is the part scripts never need. The ledger emits an event roughly every three to four seconds; by re-reading the balance on each one, your UI reflects an incoming payment without the user hitting refresh. Always pair client.on(...) with client.off(...) in the cleanup function, or you'll leak listeners every time the component re-mounts.

A payment form

Sending XRP from the UI is the same Payment transaction you built as a script — submitAndWait with autofill and a wallet — wrapped in an event handler and some state to track progress. The new concern is time: validation takes a few seconds, so you disable the button, show "sending…", and only then report the result.

// src/components/SendForm.tsx
import { useState } from 'react'
import * as xrpl from 'xrpl'
import { useXrpl } from '../hooks/useXrpl'

export function SendForm({ wallet }: { wallet: xrpl.Wallet }) {
  const { client } = useXrpl()
  const [to, setTo] = useState('')
  const [amount, setAmount] = useState('10')
  const [status, setStatus] = useState<string>('')

  async function send(e: React.FormEvent) {
    e.preventDefault()
    setStatus('sending…')

    const tx: xrpl.Payment = {
      TransactionType: 'Payment',
      Account: wallet.classicAddress,
      Destination: to,
      Amount: xrpl.xrpToDrops(amount),
    }

    try {
      const res = await client.submitAndWait(tx, { autofill: true, wallet })
      const code = res.result.meta?.TransactionResult
      setStatus(code === 'tesSUCCESS' ? `sent — ${res.result.hash}` : `failed: ${code}`)
    } catch (err) {
      setStatus(`error: ${(err as Error).message}`)
    }
  }

  return (
    <form onSubmit={send}>
      <input value={to} onChange={e => setTo(e.target.value)} placeholder="Destination r..." />
      <input value={amount} onChange={e => setAmount(e.target.value)} />
      <button type="submit" disabled={status === 'sending…'}>Send XRP</button>
      <p>{status}</p>
    </form>
  )
}

Note we check meta?.TransactionResult exactly as in your scripts: a transaction can be submitted and still fail, so tesSUCCESS — not the absence of an exception — is what tells you the ledger applied the payment. Because WalletPanel is listening on ledgerClosed, the sender's balance updates on its own a few seconds later. That decoupling — one component sends, another observes — is the React-shaped way to think about ledger state.

Tokens, trustlines, and NFTs all follow the same read pattern: ask the ledger a *_request and render the array it returns. The XRP Ledger has NFTs natively (no contract needed), so to list what an address owns you send an account_nfts request — no wallet or signing required, since you're only reading.

// src/components/NftGallery.tsx
import { useEffect, useState } from 'react'
import { useXrpl } from '../hooks/useXrpl'

export function NftGallery({ address }: { address: string }) {
  const { client, ready } = useXrpl()
  const [nfts, setNfts] = useState<any[]>([])

  useEffect(() => {
    if (!ready || !address) return
    client
      .request({ command: 'account_nfts', account: address })
      .then(res => setNfts(res.result.account_nfts))
      .catch(() => setNfts([]))
  }, [ready, address, client])

  if (nfts.length === 0) return <p>No NFTs found.</p>

  return (
    <ul>
      {nfts.map(nft => (
        <li key={nft.NFTokenID}>
          <code>{nft.NFTokenID.slice(0, 12)}…</code>
          {nft.URI && <span> {Buffer.from(nft.URI, 'hex').toString('utf8')}</span>}
        </li>
      ))}
    </ul>
  )
}

The URI field comes back hex-encoded — that's how the ledger stores it — so you decode it to read the metadata link a minter attached. Trustlines work the same way with account_lines, and the same shape extends to the course's later NFT lessons: minting is an NFTokenMint transaction through the SendForm pattern, transferring and brokering use NFTokenCreateOffer / NFTokenAcceptOffer. Once the read-and-render and the submit-and-wait patterns click, every feature is a variation on the two.

A word on keys

Notice that on testnet we hold a Wallet — and therefore its private key — in React state, in the browser. That's fine for a learning app against play money. It is not how a production dApp works. Real apps never touch the user's secret: they hand an unsigned transaction to a signer the user controls (a browser extension or hardware wallet like Xaman) and receive back something already signed. The patterns you built here — the shared client, the connection hook, the read-and-render components — stay exactly the same; only submitAndWait({ wallet }) is replaced by "ask the user's signer to sign, then submit the blob". Keep the seam between building a transaction and signing it clean, and that swap is a small one.

Check

You're done when you have a running React app that, against testnet: connects a wallet and shows its live XRP balance (updating on its own after a transfer), sends a Payment from a form and surfaces the tesSUCCESS result plus the tx hash in the UI, and renders an NFT gallery for an address via account_nfts. Submit a repo containing the shared client, the useXrpl hook, and the wallet, send, and gallery components — plus a screenshot or a validated tx hash proving a payment landed — for a peer to verify.

Assignments

0 of 3 complete

Unlocks

Finishing this module opens up: