Building a trading interface for actions that cannot be undone
- React 19
- TypeScript
- Next.js
- useReducer
- Discriminated unions
- Wagmi
- Viem
- Privy
- Tailwind CSS

Problem
This is the same project as the Hyperliquid router case below, seen from the interface side. The routing and signing problem was 'can a trade execute at all'. This one is 'can a person be trusted to press this button', and it is the harder half. A trading UI breaks the assumption most React interfaces are built on: that the user can undo. There is no undo here. A filled order is filled, and a multi-hop swap that dies between hops leaves the user holding an intermediary token they never wanted. On top of that, every number on screen is an estimate derived from an orderbook that was accurate when it was fetched and may not be now, and the user reads that number and then authorises a trade against it. The first version had the shape every tutorial teaches — a handful of useState booleans for isLoading, isExecuting, error, result. Within a day it could show a success panel while a request was still in flight, and it had no way at all to represent 'two of three hops completed', because that state had never been named. The bug was not in the logic. The bug was that the state space allowed combinations that cannot exist.
Constraints
- Every action costs real money and none of them can be reversed, so an ambiguous UI state is a financial bug, not a cosmetic one.
- Prices come from a live orderbook and go stale between fetch and execute — the interface must never present a stale estimate as if it were a quote.
- Multi-hop trades execute as separate sequential orders, so partial failure is a normal outcome and not an exception.
- Users arrive in one of several unready states — no wallet, wrong chain, no trading agent approved, market data still loading — and each needs a different next action, not a greyed-out button.
- The exchange rejects orders under $10 and requires size rounded to each pair's decimal precision, so validation had to happen before the user commits, not as a server error afterwards.
Solution
The whole flow is one reducer over a discriminated union of eight states: idle, discovering, route_found, no_route, error, executing, executed, execution_error. Each state carries exactly the data that state can have — executing carries the route and an optional currentHop, executed carries the result, no_route carries the two tokens that failed to connect — so the render path reads state.status and gets back a payload that is guaranteed to exist. There is no isLoading boolean anywhere and no way to be executing and executed at once. Multi-hop execution reports progress by dispatching HOP_PROGRESS as each hop confirms, and the result type distinguishes completed from partial: partial carries the list of hops that did succeed plus the one that failed, which is what lets the UI tell a user exactly which token they are now holding instead of showing a generic failure. The route estimate walks real orderbook levels rather than multiplying by a mid price — filling against bids or asks in sequence, volume-weighting the average, and extrapolating at the worst level when the book runs thin — and the pathfinder attaches structured warnings (stale_data, low_liquidity, high_slippage, long_route) with an info/warn/error severity that the banner renders directly. The action button is not one button with a disabled prop: it is a precondition ladder that returns a different control for each unready state, so a user who cannot trade is always told what to do next.
Key technical decisions
- A discriminated union, so impossible states cannot be rendered
- Four independent booleans describe sixteen combinations, and in a trading UI perhaps six of them are real. The rest are bugs waiting for a slow network. Replacing them with a union of eight named states does two things at once. It deletes the invalid combinations at the type level — there is no value of RouteState that is both executing and executed, so no render path has to defend against it. And it forces every state to be named before it can be rendered, which is how 'two of three hops completed' got discovered: not in production, but while enumerating the union and finding I had no name for it. The reducer has no conditional logic at all, just a switch mapping each action to the state it produces, which means the entire legal transition graph is readable in one screen.
- Partial failure is a first-class result, not an error
- A three-hop swap is three separate orders. If the second fails, the first has already filled and the user is holding an intermediary token — USDC or HYPE — that they never asked for. Treating that as 'trade failed' is actively harmful: it tells the user nothing happened when in fact their balance has changed. So the multi-hop result type has three outcomes rather than two, and partial carries completedHops plus the failed one. The UI renders 'Partial Execution — 2 of 3 hops completed' with the specific hops listed, so the user can see what they now hold and decide what to do about it. This is the clearest example I have of a state that only exists because the domain is irreversible, and it would never have appeared from a boolean-shaped design.
- Chain hops on filled amounts, never on estimates
- The obvious way to run hop two is to feed it the amount hop one was predicted to produce. That is wrong every time, because the estimate came from an orderbook snapshot and the fill happened against the live book. The executor reads the actual totalSz and avgPx off each confirmed order and computes the next hop's input from those. It is a small change and it is the difference between a router that works on paper and one that survives a moving market. The same instinct shows up in size rounding: sizes are floored to the pair's decimal precision rather than rounded, because rounding up produces an order for slightly more than the user holds, which the exchange rejects at the worst possible moment.
- Estimates walk the book, and say so when they cannot be trusted
- Quoting mid price times amount is the standard shortcut and it lies as soon as the order is bigger than the top level. The estimator fills sequentially through bids or asks, volume-weights the result, and when the book has less depth than the order it extrapolates at the worst available level — a deliberately pessimistic number, because on this side of the trade an optimistic estimate is the one that hurts. What matters as much is what the interface admits. Route discovery emits structured warnings with a severity, including a stale_data warning computed from the orderbook's own timestamp that tells the user how many seconds old the quote is. The estimator also carries a comment naming what it does not model — the market impact of the user's own order. An estimate presented without its uncertainty is a quote, and this is not allowed to be a quote.
- The action button is a precondition ladder, not a disabled state
- A user can be blocked from trading for five different reasons: no wallet connected, connected to the wrong chain, no trading agent approved, market data still loading, or an incomplete order form. The lazy version is one button with disabled and a tooltip. Instead the component returns a different control for each rung — Connect Wallet, Switch to Arbitrum, Approve Trading Agent — so the blocked state always doubles as the fix. Only when every precondition is satisfied does the real execute button appear, labelled with what it will actually do, including the hop count for a multi-hop route. A disabled button tells the user they are stuck; a ladder tells them what to press.
- Where this connects to the rest of my thinking
- Two things here are the same lesson I keep arriving at from different directions. The first is that a race condition in a UI is almost always a state-modelling failure rather than a timing failure — I wrote about that separately in 'Your state updates are lying to you', and this project is where the argument came from. The second is the split between derived and authoritative data, which is exactly the trade I made on the other side of the stack in the indexer case: an estimate and a fill are different kinds of fact, and an interface that renders them identically is lying by omission. What this case still owes is a proper freshness contract — right now staleness is surfaced as a warning, but the estimate is not re-fetched or invalidated automatically when it ages past a threshold, which is the fix I would ship next.
Outcome
Eight named states, one reducer, and no boolean flags anywhere in the execution path. The impossible renders the first version produced — a result panel over an in-flight request, a silent failure that had actually moved the user's balance — are not fixed so much as unrepresentable: there is no value of the state type that expresses them. Partial multi-hop execution reports which hops filled and which token the user is now holding. Estimates walk real orderbook depth and carry their own staleness, so the interface never presents a snapshot as a quote. The broader lesson transferred directly to everything I have built since: when a domain has no undo, the interface's job is not to prevent mistakes with confirmations, it is to make the true state of the system legible at every moment — including the states nobody wants to think about.