X-Change Guide

State & data fetching

The frontend keeps two kinds of state separate: client state (wallet + tokens, with Zustand) and server state (everything from the API, with React Query).

Zustand — client state

Zustand is a minimal state store. Two stores exist, both persisted to localStorage:

store/wallet.ts + store/auth.ts
1// wallet store — holds the PRIVATE KEY (self-custody!)
2useWalletStore: { wallet, create(), importSecretKey(hex), clear() }
3// persisted under localStorage key "xchange-wallet"
4
5// auth store — holds the JWT tokens
6useAuthStore: { address, accessToken, refreshToken, set(), clear() }
7// persisted under localStorage key "xchange-auth"
Storage caveat — know it for defense
The JWT and the private key both live in localStorage. That's convenient but not the most hardened storage (XSS could read it). Real wallets use browser extensions or hardware keys. It's fine for a demo — and great to show you understand the trade-off.

Note the refreshToken is stored but not actually used by the frontend — there's no automatic refresh flow. A candidate improvement to mention.

React Query — server state

TanStack React Query fetches data from the API, caches it, and re-polls on an interval so the pages feel live:

Example — polling the order book every 3 seconds
1const { data: book } = useQuery(
2 ["book"],
3 () => api.orderbook(10),
4 { refetchInterval: 3_000 } // refresh every 3 seconds
5);

Poll intervals used across the app: order book 3s, trades 5s, height 5s, blocks 6s, balances 8s, validators/positions 10s, health 10s.

The API client

lib/api.ts is one typed wrapper around fetch. The base URL comes from NEXT_PUBLIC_API_URL (localhost in dev, the Railway URL in production). Every endpoint is a method on the api object:

lib/api.ts (abridged)
1const BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
2
3async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
4 const res = await fetch(`${BASE}${path}`, {
5 ...init,
6 headers: { "content-type": "application/json", ...init.headers },
7 });
8 if (!res.ok) throw new ApiError(res.status, (await res.json()).error);
9 return res.json();
10}
11
12export const api = {
13 health: () => request("/health"),
14 challenge: (address) => request("/auth/challenge", { method: "POST", body: JSON.stringify({ address }) }),
15 login: (body) => request("/auth/login", { method: "POST", body: JSON.stringify(body) }),
16 submit: (tx) => request("/tx/submit", { method: "POST", body: JSON.stringify({ tx }) }),
17 orderbook: (depth) => request(`/trade/orderbook?depth=${depth}`),
18 // protected ones pass a token:
19 placeOrder: (token, body) => request("/trade/order", {
20 method: "POST",
21 headers: { Authorization: `Bearer ${token}` },
22 body: JSON.stringify(body),
23 }),
24};

How it connects to the backend

The frontend talks only to the Rust node over HTTP. There is no direct database access, no WebSockets — just JSON requests, many of them polling. This keeps the architecture simple and the API surface small. The Backend full API reference lists every endpoint this client can call.

The one frontend env var

  • NEXT_PUBLIC_API_URL — the backend's base URL. Defaults to http://localhost:8080 in dev; set to the Railway URL in production (also baked into wrangler.toml).