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:
| Mode | How you opt in | First render sees |
|---|---|---|
| Asynchronous (default) | nothing | an empty pool, until the restore pass finishes |
| Synchronous seeding | initialState from readWalletSnapshot | the 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).reasonis aConnectionError. 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, andswitchChainare absent until the live adapter lands. Itsconnect,getAccounts, andgetSignerreject with aShadowConnectorError. useSigner()anduseBalance()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, anduseIsReconnecting(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.