butr
Core concepts

Hydration

butr restores the previous session either asynchronously on mount, or synchronously from a server-rendered snapshot.

butr has two hydration modes, and which one you get depends on whether you pass initialState to the provider:

ModeHow you opt inFirst render sees
Asynchronous (default)nothingan empty pool, until the restore pass finishes
Synchronous seedinginitialState from readWalletSnapshotthe persisted pool, on both server and client

Everything below describes the asynchronous default. Jump to synchronous seeding for the other one.

The restore pass

manager.start() (the provider calls it in an effect) loads the persisted state once and silently reconnects every stored wallet: Wallet Standard's silent connect, an eth_accounts read on EIP-1193. A wallet that would have to prompt fails the restore instead. Each silent reconnect gets 15 seconds, so a wallet that ignores silent and pops a dialog cannot hold the pass open.

The catch: wallet adapters announce themselves asynchronously (EIP-6963 dispatches events; the Wallet Standard module is lazily imported). So a stored wallet's adapter may not exist yet when the pass runs. Such an entry waits, dormant: persisted, not live. The manager restores it the moment a source announces its adapter, with no wiring on your side.

The three buckets

onHydrated fires once, after the start-up pass, with a HydrationOutcome:

type HydrationOutcome = {
  restoredIds: ReadonlyArray<string>; // back in the pool; usable now
  pendingIds: ReadonlyArray<string>; // adapter not announced yet; restored when it is
  dropped: ReadonlyArray<{ connectorId: string; reason: ConnectionError }>; // silent reconnect failed
};
  • restoredIds: live in the pool, use immediately.
  • pendingIds: not failures. Each is restored as soon as its adapter is announced, usually within a few hundred milliseconds of mount.
  • dropped: the silent reconnect failed (the wallet is locked, or revoked the site). reason is a ConnectionError. A dropped entry stays persisted, so the next load tries again; surface "Couldn't reconnect Phantom; connect again" if you want.
import type { WalletManagerConfig } from "@usebutr/core";
import { autoDiscovery } from "@usebutr/wallets";

const config: WalletManagerConfig = {
  onHydrated: (outcome) => {
    console.log("restored", outcome.restoredIds);
    console.log("pending", outcome.pendingIds);
    console.log("dropped", outcome.dropped);
  },
  sources: [autoDiscovery()],
};

Without onHydrated, the manager logs each dropped entry with console.warn. onConnect(wallet, { reconnected: true }) also fires for every silent restore, including late ones, so it is the one place to observe a wallet going live.

The user wins

The restore pass never overrides what the user does while it runs:

  • If the user connects or picks a wallet during the pass, the stored active wallet and selection are not applied over that choice.
  • If the user disconnects a wallet whose silent reconnect is in flight, the reconnect is discarded and the session it reopened is closed again.

Why useIsHydrated() matters

In the asynchronous mode the pool is empty until the pass finishes. If you render based on "is anything connected" before that, you flash a logged-out UI on every reload, so gate on it:

const isHydrated = useIsHydrated();
if (!isHydrated) return <p>Loading…</p>;

This gate is only needed in the asynchronous mode. With initialState the manager is already hydrated on the first render, and the gate would hide a shell you could have painted immediately.

Synchronous seeding (SSR)

Pass initialState and the manager starts with isHydrated: true, the pool populated, and every seeded wallet id in reconnectingIds. The primary hooks (useWallet, useConnectedWallets, useAccounts) return values from render zero on both server and client, so a server-rendered page can paint the user's address with no flash.

Read the snapshot on the server with readWalletSnapshot, which parses butr's own cookies without needing an adapter (impossible on a server):

// app/layout.tsx
import { readWalletSnapshot } from "@usebutr/core";
import { cookies } from "next/headers";

// From a module without "use client": imported from a client module, the
// constant would be a client reference, not the string.
import { STORAGE_KEY_PREFIX } from "../wallet-keys";

const RootLayout = async ({ children }: { children: ReactNode }) => {
  const initialState = readWalletSnapshot((await cookies()).getAll(), {
    keyPrefix: STORAGE_KEY_PREFIX,
  });

  return <WalletProvider initialState={initialState}>{children}</WalletProvider>;
};

The seeded entries are backed by placeholder adapters until the real wallet announces itself, so they carry data but cannot act. Three things follow:

  • The placeholder has none of the optional methods, so signMessage, sendTx, and switchChain are absent until the live adapter lands. Its connect, getAccounts, and getSigner reject with a ShadowConnectorError.
  • useSigner() and useBalance() stay "idle" for a seeded wallet rather than reporting an error, and start loading once the live adapter arrives.
  • useConnectionStatus() returns "reconnecting" while the active wallet is seeded, and useIsReconnecting(id) answers the same question per wallet. Gate any "sign" affordance on it.

If a seeded wallet's silent reconnect fails, which a locked wallet will do, the manager drops it from the pool rather than leaving an unusable placeholder, and keeps it persisted for the next load. If its adapter never announces (the extension was uninstalled), it stays reconnecting: there is no timeout for a wallet that has not appeared yet. disconnect(id) removes it.

The full walkthrough, including the cookie storage driver, is in SSR without a flash.

Outside React

createWalletManager(config, { initialState }) is the same manager without the provider. Call start() yourself; it hydrates once per manager, so calling it again after its cleanup (a React StrictMode remount, for example) does not restore twice.

Source: packages/core/src/store/wallet-manager.ts, packages/core/src/store/reducer.ts, packages/core/src/types/manager.ts.