Handle phases & errors

4 min read
Guides
Web SDK

React to wallet phases

phase from useWallet() describes where the wallet is in its lifecycle. Use a switch or a series of conditionals at the root of your app to render the right screen for each phase. The value updates reactively, so the UI transitions automatically as the wallet moves through phases.

Phase Meaning
loading The runtime assets have not finished loading.
runtimeReady The runtime is usable, but the daemon has not started.
starting start() has been called and has not yet resolved.
needsWallet No wallet exists yet; create or restore one.
locked A wallet exists but is locked; unlock it.
syncing The wallet is unlocked and catching up with the chain.
restoring A background restore (useWalletRestore()) is bringing a freshly restored wallet up; setting recoverState: true on the request additionally tracks the recovery scan through the recovery state.
ready The wallet is unlocked and ready to use.
stopping stop() has been called and has not yet resolved.
stopped The daemon has stopped.
error The runtime failed to start, start()/stop() failed, or a background process (sync poll, activity stream, refresh) exhausted its failure budget.

info.walletState (from useWalletInfo()) carries the underlying WalletState string (none, locked, ready, or syncing) when you need the daemon-level detail instead of the broader RuntimePhase.

phases.tsx
import { useWallet } from '@lightninglabs/wavelength-react';
function WalletGate({ children }: { children: React.ReactNode }) {
const { phase } = useWallet();
switch (phase) {
case 'loading':
case 'runtimeReady':
case 'starting':
return <p>Starting wallet runtime...</p>;
case 'needsWallet':
return <CreateWalletScreen />;
case 'locked':
return <UnlockScreen />;
case 'syncing':
case 'restoring':
return <p>Syncing with the wallet server...</p>;
case 'ready':
return <>{children}</>;
case 'stopping':
case 'stopped':
return <p>Wallet stopped.</p>;
case 'error':
return <p>Runtime failed to start.</p>;
default:
return null;
}
}

A failed start() or stop() moves phase straight to 'error'; there is no separate 'startFailed'/'stopFailed' phase to branch on. Read error from useWallet() for the reason.

The Error | null convention

Every mutation hook (useWalletCreate, useWalletRestore, useWalletUnlock, useWalletDeposit, useWalletReceive, useWalletPrepareSend, useWalletSend, useWalletRefresh, and the create/open pair on useWalletPasskey) exposes the same throw-and-capture shape: an action function, a verb-prefixed <verb>Pending: boolean, a verb-prefixed <verb>Error: Error | null, and (except useWalletRefresh and useWalletPasskey, neither of which exposes a <verb>Data field) a verb-prefixed <verb>Data, the last successful result. The error field is always Error | null, never a string, so .message is always safe to read. Calling the action clears the previous error/data, awaits the underlying engine call, and on completion both updates the hook’s state and settles the returned promise the normal way: resolves with the result, or rejects with the same Error instance that lands in the error field. So you can read the error field for a declarative render, or await/try/catch the action for imperative flow, whichever fits the call site. reset<Verb>() clears the pending/error/data trio back to idle without calling anything.

error at the top level, from useWallet(), is the same Error | null shape but tracks fatal runtime-level failures instead of a single mutation: it is set when the initial runtime load fails, when start()/stop() fails, or after a background process exhausts its failure budget, and it clears on the next successful start().

errors.tsx
import { useWalletSend } from '@lightninglabs/wavelength-react';
function PayButton({ bolt11 }: { bolt11: string }) {
const { send, sendError, sendPending, resetSend } = useWalletSend();
const pay = async () => {
resetSend();
try {
await send({ invoice: bolt11 });
} catch (err) {
// sendError is already set for a declarative render; err is the same
// Error instance for imperative handling right here.
console.error('Payment failed:', err);
}
};
return (
<>
<button onClick={pay} disabled={sendPending}>Pay</button>
{sendError && <p role="alert">{sendError.message}</p>}
</>
);
}

Interpret error codes

Every error the Wavelength SDK originates extends WavelengthError and carries a code string that identifies the failure reason at the machine level. Match on err.code to show a specific message rather than a generic fallback. Named SDK codes include runtime_not_ready, asset_load_failed, asset_integrity_failed, worker_error, wallet_locked, and runtime_lock_unavailable (the browser refused or cancelled the runtime lock request itself, which says nothing about another tab; offer a plain retry). Daemon-originated errors currently use wavelength_error.

wallet_locked is worth handling on its own, and it belongs on the path that starts the runtime rather than on a payment. Despite the name it has nothing to do with the locked phase: that one means a wallet on this device is waiting for its password or passkey, while this means another tab owns the runtime. The daemon’s browser storage is exclusive to one runtime per origin, so starting the wallet while another tab already runs it fails immediately with this code. It is an expected condition rather than a fault: tell the user to close the other tab, and keep a retry affordance so they can continue once they have.

start.tsx
import { useState } from 'react';
import {
useWallet,
type RuntimeConfig,
type WavelengthError,
} from '@lightninglabs/wavelength-react';
function StartButton({ config }: { config: RuntimeConfig }) {
const { start } = useWallet();
const [notice, setNotice] = useState('');
const run = async () => {
try {
await start(config);
} catch (err) {
// Branch on the code property rather than instanceof: a duplicate
// bundled copy of core fails instanceof across the bundle boundary.
if ((err as WavelengthError)?.code === 'wallet_locked') {
setNotice('Already open in another tab. Close it, then try again.');
return;
}
setNotice('The wallet runtime could not start.');
}
};
return (
<>
<button onClick={run}>Start wallet</button>
{notice && <p role="alert">{notice}</p>}
</>
);
}
errors.tsx
import { useWalletSend, WavelengthError } from '@lightninglabs/wavelength-react';
function PayButton({ bolt11 }: { bolt11: string }) {
const { send, sendError, sendPending, resetSend } = useWalletSend();
const pay = async () => {
resetSend();
try {
await send({ invoice: bolt11 });
} catch (err) {
if (err instanceof WavelengthError) {
switch (err.code) {
case 'runtime_not_ready':
console.error('Runtime still loading.');
break;
case 'asset_load_failed':
console.error('Check runtimeBaseUrl and hosted assets.');
break;
case 'asset_integrity_failed':
console.error('Hosted assets do not match the pinned release.');
break;
default:
console.error('Payment failed:', err.message);
}
}
}
};
return (
<>
<button onClick={pay} disabled={sendPending}>Pay</button>
{sendError && <p role="alert">{sendError.message}</p>}
</>
);
}