butr
Guides

Custom storage

Back persistence with cookies, memory, React Native AsyncStorage, or a WalletPersistence of your own.

The manager persists through config.storage, a WalletPersistence. There are two levels to customise it at:

  • A storage driver keeps butr's key layout and swaps where the strings go: cookies, memory, AsyncStorage. Wrap it with createWalletStorage.
  • A WalletPersistence replaces the layout entirely: one key, a server, IndexedDB. Implement load and save.

See persistence for what is stored and when.

Drivers

A driver is a three-method get / set / remove interface that may be async:

type StorageDriver = {
  getItem: (key: string) => MaybePromise<string | null>;
  removeItem: (key: string) => MaybePromise<void>;
  setItem: (key: string, value: string) => MaybePromise<void>;
};

createWalletStorage takes a persistent driver (pool, selection, active id) and a session driver (the disconnect intent), defaulting to localStorage and sessionStorage:

import type { WalletManagerConfig } from "@usebutr/core";
import {
  createCookieStorageDriver, // cookies: domain, path, sameSite, secure, maxAgeSeconds
  createMemoryStorageDriver, // in-memory, no persistence
  createWalletStorage,
} from "@usebutr/core";
import { autoDiscovery } from "@usebutr/wallets";

const config: WalletManagerConfig = {
  sources: [autoDiscovery()],
  storage: createWalletStorage({
    keyPrefix: "myapp",
    persistent: createCookieStorageDriver({
      domain: "example.com",
      maxAgeSeconds: 604_800,
      path: "/",
      sameSite: "lax",
      secure: true,
    }),
    session: createMemoryStorageDriver(),
  }),
};

With storage set, storageKeyPrefix is ignored; the prefix goes to createWalletStorage, and readWalletSnapshot needs the same one.

React Native / Expo (AsyncStorage)

Drivers may be async, so any AsyncStorage-like API works. This is the exact demo-expo-web driver:

async-storage-driver.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { StorageDriver } from "@usebutr/core";

const asyncStorageDriver: StorageDriver = {
  getItem: (key) => AsyncStorage.getItem(key),
  setItem: (key, value) => AsyncStorage.setItem(key, value),
  removeItem: (key) => AsyncStorage.removeItem(key),
};

export { asyncStorageDriver };

Wire it into the config:

wallet-provider.tsx
import type { WalletManagerConfig } from "@usebutr/core";
import { createWalletStorage } from "@usebutr/core";
import { WalletManagerProvider } from "@usebutr/react";
import { autoDiscovery } from "@usebutr/wallets";
import type { ReactNode } from "react";

import { asyncStorageDriver } from "./async-storage-driver";

const config: WalletManagerConfig = {
  sources: [autoDiscovery()],
  storage: createWalletStorage({
    keyPrefix: "butr-demo",
    persistent: asyncStorageDriver,
    session: asyncStorageDriver,
  }),
};

const WalletProvider = ({ children }: { children: ReactNode }) => (
  <WalletManagerProvider config={config}>{children}</WalletManagerProvider>
);

AsyncStorage has no true session-storage equivalent. The demo backs both drivers with the same store and accepts that session entries outlive the session. butr's session slot holds only the isUserDisconnected flag, which the next connect clears, and butr itself never reads it to decide anything. If your own auto-connect logic depends on it, back session with createMemoryStorageDriver() instead, which resets on every app start.

Your own WalletPersistence

For anything the driver layout doesn't fit, implement the interface directly. The manager hands save the complete state after every change, so an implementation never merges, diffs, or queues:

import type { PersistedWalletState, WalletPersistence } from "@usebutr/core";

const EMPTY: PersistedWalletState = {
  activeConnectorId: null,
  isUserDisconnected: false,
  pool: {},
  selection: {},
};

const storage: WalletPersistence = {
  load: async () => (await db.get<PersistedWalletState>("wallets")) ?? EMPTY,
  save: async (state) => {
    await db.put("wallets", state);
  },
};
  • load runs once, when the manager starts. A rejection is reported through onStorageError and treated as empty storage, so validate what you read rather than trusting it.
  • save never runs before load has resolved, and saves never overlap: while one is in flight, later changes collapse into one follow-up call with the latest state.
  • A rejected save is reported through onStorageError and does not affect the in-memory state.
  • readWalletSnapshot parses butr's own key layout, so server rendering from a custom layout means building the WalletSnapshot yourself.

Source: apps/demo-expo-web/src/async-storage-driver.ts, apps/demo-expo-web/src/wallet-provider.tsx, and packages/core/src/storage in the butr repository. See also persistence concepts.