intermediate 120 min

Build a Mini-Explorer

Project-based module: build a block explorer from scratch. Learn requests, subscriptions, ledger data, and real-time transaction streams.

Prerequisites

Complete these before starting this module:

What you'll learn

  • Distinguish one-time `request` calls from persistent `subscribe` streams and choose the right one for each panel.
  • Fetch ledger data with the `ledger` command and render the latest closes with their transaction counts.
  • Subscribe to the `ledger` and `transactions` streams and update the UI in real time as the ledger advances.
  • Read the key fields of a transaction stream event (hash, type, result, ledger_index) and display them safely.
  • Tear down subscriptions and connections cleanly so your explorer does not leak sockets.
Complete this module by peer review. Jump to assessment

Overview

You've used a block explorer a hundred times — paste an address, watch transactions scroll by, click a ledger to see what closed. In this project you build your own. Not a toy that fakes data, but a real one wired straight into the XRP Ledger over the same WebSocket the big explorers use.

An explorer is the perfect capstone for everything you've learned, because it only ever reads. There are no keys to manage, no transactions to sign, nothing that can go wrong on-chain. That frees you to focus on the two ideas this module is really about: the difference between asking the ledger a question once and asking it to keep talking to you forever. Get that distinction right and the rest is just rendering.

By the end you'll have a React app with two live panels: a ledger viewer that lists recent ledger closes with their transaction counts, and a live transaction feed that streams validated transactions the instant they land. Everything runs against the public testnet, so there's nothing to fund and nothing to break.

We'll keep all the XRPL logic in plain xrpl.js and let React handle the rendering — the same split you'd use in any production dapp.

Requests vs. subscriptions

This is the whole module in one idea, so let's nail it first.

A request is a question with one answer. You ask "what's the latest ledger?" and the server replies once. In xrpl.js that's client.request(...) — it returns a promise that resolves with the response and then it's done. Perfect for things that don't change while you look at them: a specific ledger, an account's history, a transaction by hash.

A subscription is a question that never stops answering. You tell the server "from now on, tell me every time a ledger closes" and it pushes you a message on its own schedule — once every few seconds, forever, until you unsubscribe. In xrpl.js that's client.request({ command: 'subscribe', ... }) to start it, plus an event listener to catch the pushes. Perfect for anything that's alive: new ledgers, new transactions.

Mixing these up is the classic beginner trap. If you poll a request in a loop to fake real-time, you hammer the server and still miss things between polls. If you try to await a subscription's data, you wait forever. Requests pull; subscriptions push. Use the right one.

A good rule of thumb: reach for a request whenever the answer is fixed in the past — a ledger that already closed, a transaction that already settled, an account's history. Those facts don't move, so asking once is enough. Reach for a subscription whenever you want to track the present as it becomes the past — the tip of the ledger, the firehose of incoming transactions. The explorer you're building needs both, and the most natural designs use a request to draw the initial picture and a subscription to keep it current. That's exactly how each panel here works: one request paints the starting state, then a stream takes over and feeds every change.

One more practical consequence: subscriptions are stateful on the server. The server remembers that your connection asked to be notified, which is why a dropped socket silently stops your feed and why you must explicitly unsubscribe (or disconnect) to stop it. A request has no such afterlife — it completes and is forgotten. Keep that asymmetry in mind; most explorer bugs are really subscription-lifecycle bugs in disguise.

Connect once

Open a single connection and share it across the whole app. xrpl.js keeps the WebSocket alive, which is exactly what subscriptions need.

import * as xrpl from 'xrpl'

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

await client.connect()
console.log('Connected:', client.isConnected())

In a React app you'd create this client once — in a top-level effect or a small provider — and never inside a component that re-renders. One socket, reused everywhere.

Fetch a ledger (a request)

Start with the simplest read: ask for the most recently validated ledger. The ledger command takes ledger_index: 'validated' to mean "the latest fully-agreed-upon ledger" (as opposed to 'current', which is still in progress and can change).

const response = await client.request({
  command: 'ledger',
  ledger_index: 'validated',
  transactions: true, // include the list of tx hashes in this ledger
})

const ledger = response.result.ledger
console.log({
  index: response.result.ledger_index,
  hash: ledger.ledger_hash,
  closeTime: ledger.close_time,
  txCount: ledger.transactions?.length ?? 0,
})

That's a one-shot request: it resolves with this exact ledger and stops. To show the latest ten, you don't loop and poll — you grab the current index once, then request each prior ledger by number. Ledger indexes are sequential integers, so "the previous ledger" is simply index - 1:

async function getRecentLedgers(client: xrpl.Client, count = 10) {
  const tip = await client.request({ command: 'ledger', ledger_index: 'validated' })
  const latest = tip.result.ledger_index

  const indexes = Array.from({ length: count }, (_, i) => latest - i)

  return Promise.all(
    indexes.map(async (index) => {
      const res = await client.request({
        command: 'ledger',
        ledger_index: index,
        transactions: true,
      })
      return {
        index,
        hash: res.result.ledger_hash,
        txCount: res.result.ledger.transactions?.length ?? 0,
      }
    }),
  )
}

This is your ledger viewer's data source. Notice it runs once when the panel loads — a snapshot of the recent past. Keeping it fresh is a job for a subscription, not a faster loop.

Subscribe to ledger closes (a stream)

Now make it live. Subscribing to the ledger stream tells the server to push you a small summary message every time a ledger closes — roughly every 3–5 seconds.

// Start the subscription
await client.request({
  command: 'subscribe',
  streams: ['ledger'],
})

// Handle every push
client.on('ledgerClosed', (event) => {
  console.log('New ledger:', {
    index: event.ledger_index,
    hash: event.ledger_hash,
    txCount: event.txn_count,
    time: event.ledger_time,
  })
})

Two things to internalize. First, subscribe returns once (confirming the subscription is set up), but the real data arrives later through the 'ledgerClosed' event — that's the push. Second, the stream's event uses slightly different field names than the ledger request: here the transaction count is txn_count, the index is ledger_index, and ledger_time is seconds since the Ripple Epoch (Jan 1, 2000 UTC), not Unix time. Convert it with xrpl.rippleTimeToISOTime() before showing it to a human.

In React, each ledgerClosed event becomes a state update: prepend the new ledger to your list and trim it back to ten. The viewer now updates itself, hands-free.

Subscribe to transactions (the live feed)

Same pattern, different stream. Add 'transactions' to your subscription and the server pushes you every transaction the moment it's validated.

await client.request({
  command: 'subscribe',
  streams: ['ledger', 'transactions'],
})

client.on('transaction', (event) => {
  // Only show fully validated transactions
  if (!event.validated) return

  // The transaction object's field name changed across API versions:
  // it's `tx_json` in API v2 and `transaction` in v1. Read both.
  const txObj = event.tx_json ?? event.transaction

  console.log({
    hash: event.hash,
    type: txObj?.TransactionType,
    result: event.engine_result,        // e.g. 'tesSUCCESS'
    ledger: event.ledger_index,
  })
})

A transaction stream event carries everything your feed needs: hash (the unique id), the transaction object itself (including TransactionType, Account, Destination, Amount), meta (the full outcome metadata), engine_result (the result code like tesSUCCESS), and validated (the boolean that tells you it's final). One sharp edge: the transaction object's key is tx_json under API v2 but transaction under API v1, and which one you get depends on the API version your client negotiated — so read event.tx_json ?? event.transaction rather than hardcoding one. On a busy network these arrive fast — several per second — so in React, cap your feed at, say, the most recent 50 rows so the DOM doesn't grow without bound.

Guard your rendering: not every transaction has a Destination or an XRP Amount (an OfferCreate or TrustSet looks nothing like a Payment). Read fields defensively and show "—" when one is missing, rather than crashing the whole feed on an unexpected shape.

Wiring it into React

The shape that keeps this clean: do the XRPL work in an effect, push results into state, render the state.

import { useEffect, useState } from 'react'
import * as xrpl from 'xrpl'

type Tx = { hash: string; type?: string; result: string }

export function LiveFeed({ client }: { client: xrpl.Client }) {
  const [txs, setTxs] = useState<Tx[]>([])

  useEffect(() => {
    const onTx = (event: any) => {
      if (!event.validated) return
      const txObj = event.tx_json ?? event.transaction // v2 vs. v1 field name
      setTxs((prev) =>
        [
          { hash: event.hash, type: txObj?.TransactionType, result: event.engine_result },
          ...prev,
        ].slice(0, 50),
      )
    }

    client.on('transaction', onTx)
    client.request({ command: 'subscribe', streams: ['transactions'] })

    // Cleanup: stop listening and unsubscribe when the panel unmounts
    return () => {
      client.off('transaction', onTx)
      client.request({ command: 'unsubscribe', streams: ['transactions'] })
    }
  }, [client])

  return (
    <ul>
      {txs.map((tx) => (
        <li key={tx.hash}>
          {tx.type ?? '—'} · {tx.result} · {tx.hash.slice(0, 12)}…
        </li>
      ))}
    </ul>
  )
}

The cleanup function is not optional. Every client.on(...) you add must be paired with a client.off(...), and every subscribe with an unsubscribe. Skip it and a remounting component stacks duplicate listeners, each firing on every transaction — your feed multiplies, your app slows, and you'll spend an afternoon hunting a "ghost" bug that's really just leaked subscriptions.

Putting it together

You now have every piece:

  • Ledger viewer — one getRecentLedgers() request on load for the initial list, then a ledger subscription that prepends each new close.
  • Live feed — a transactions subscription that streams validated transactions, capped and rendered defensively.

Link each ledger hash and transaction hash out to the Testnet Explorer so you can cross-check your data against the reference explorer — if your transaction counts and hashes match theirs, you've built the real thing.

Check

You're done when your explorer, running against the testnet, shows two working panels: a ledger viewer listing the latest ~10 ledger closes with their transaction counts, and a live transaction feed that updates in real time as transactions are validated — driven by a WebSocket subscribe, not a polling loop. Cross-check a few ledger hashes and transaction counts against the Testnet Explorer to confirm your data is real, and make sure subscriptions are torn down on unmount. Submit a repo containing the explorer plus a short note on which calls are requests vs. subscriptions, for a peer to verify.

Assignments

0 of 2 complete