Full-stack dApp development: account creation UI, XRP transfers, trustlines, and NFT operations in a React application.
What you'll learn
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.
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.
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.
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.
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.
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.
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.
Resources
Assignments
0 of 3 complete