Join our community of builders on

Telegram!Telegram

Storage

@openzeppelin/ui-storage provides client-side persistence for UIKit applications. It is built on Dexie.js (an IndexedDB wrapper) and ships a plugin system for extending storage with domain-specific functionality.

Installation

pnpm add @openzeppelin/ui-storage

Why Use the Storage Plugin?

Browser applications commonly reach for localStorage to persist user data. While fine for a handful of string values, localStorage hits hard limits as your app grows:

ConcernlocalStorage@openzeppelin/ui-storage (IndexedDB)
Storage quota~5 MB per originHundreds of MB, often limited only by available disk space
Data modelFlat key-value strings; every read/write requires JSON.parse/JSON.stringifyStructured object stores with typed records, indexes, and compound queries
PerformanceSynchronous: blocks the main thread on every callAsynchronous: all reads and writes are non-blocking
QueryingFull-scan only; no way to filter or sort without loading everythingIndexed lookups and range queries via Dexie.js
Concurrent tabsNo built-in synchronization; race conditions on simultaneous writesTransactional; supports multi-tab coordination out of the box
Schema evolutionManual: you must handle migrations yourselfDeclarative versioned schemas with automatic upgrade migrations

Beyond these raw IndexedDB advantages, the storage plugin adds an opinionated layer designed for blockchain UIs:

  • Typed base classes: EntityStorage<T> and KeyValueStorage give you CRUD, validation, quota handling, and timestamps without boilerplate.
  • Plugin system: domain-specific plugins (like the built-in account alias plugin) drop into any Dexie database and integrate with UIKit providers automatically.
  • Reactive hooks: useLiveQuery re-renders components when the underlying IndexedDB data changes, including changes from other browser tabs.
  • Quota-safe writes: the withQuotaHandling wrapper catches QuotaExceededError and surfaces a typed error code so your UI can handle it gracefully instead of silently failing.

When Does It Make Sense?

Use @openzeppelin/ui-storage when your application needs to persist structured, queryable data on the client, especially data that grows over time or must survive page reloads. Common examples include:

  • Contract history and recent contracts: the Role Manager persists recently accessed contracts per network in a RecentContractsStorage built on EntityStorage. Records are indexed by [networkId+address] for fast lookups and sorted by lastAccessed so the most recent entries always appear first. The same result in localStorage would require deserializing, sorting, and re-serializing an entire array.

  • UI configuration and form state: the UI Builder stores complete contract UI configurations (including large ABIs and compiled contract definitions) in a ContractUIStorage entity store. With records that can reach tens of megabytes, localStorage's 5 MB limit would be a non-starter. The storage plugin also powers the builder's import/export and multi-tab auto-save features.

  • User preferences and settings: both projects use KeyValueStorage for simple typed preferences (theme, active network, page size), a lightweight alternative that still benefits from async I/O and schema versioning.

  • Address book and aliases: the built-in account alias plugin persists address-to-name mappings in IndexedDB and wires them into UIKit's AddressLabelProvider and AddressSuggestionProvider. Every AddressDisplay and AddressField in the component tree resolves labels automatically without any per-component wiring.

If your app only stores a single flag or token, localStorage is perfectly adequate. Reach for the storage plugin when you need indexed queries, large payloads, multi-tab safety, or domain-specific persistence that integrates with the rest of the UIKit ecosystem.

Core Abstractions

The package exposes two base classes that your application can extend:

ClassDescription
EntityStorage<T>Generic IndexedDB store for typed entities. Handles create, read, update, delete, and query.
KeyValueStorageSimple key-value store backed by IndexedDB. Useful for persisting settings and preferences.

Both classes wrap a Dexie database instance and can be composed with plugins.

Account Alias Plugin

Account alias plugin

The built-in account alias plugin persists address-to-name mappings. It powers the AddressBookWidget and integrates with AddressLabelProvider and AddressSuggestionProvider to resolve human-readable labels automatically across all AddressDisplay and AddressField components.

Setup

Create a Dexie database instance with the alias schema, then use the provided hooks:

import {
  createDexieDatabase,
  ALIAS_SCHEMA,
  useAliasLabelResolver,
  useAliasSuggestionResolver,
  useAddressBookWidgetProps,
} from '@openzeppelin/ui-storage';
import Dexie from 'dexie';

const db = createDexieDatabase(new Dexie('my-app'), ALIAS_SCHEMA);

Integration with Address Providers

Mount the providers near the root of your app to activate automatic label resolution. The useAliasLabelResolver and useAliasSuggestionResolver hooks return props that can be spread directly into the respective providers:

import {
  AddressLabelProvider,
  AddressSuggestionProvider,
} from '@openzeppelin/ui-components';
import {
  createDexieDatabase,
  ALIAS_SCHEMA,
  useAliasLabelResolver,
  useAliasSuggestionResolver,
} from '@openzeppelin/ui-storage';
import Dexie from 'dexie';

const db = createDexieDatabase(new Dexie('my-app'), ALIAS_SCHEMA);

function App() {
  const labelResolver = useAliasLabelResolver(db);
  const suggestionResolver = useAliasSuggestionResolver(db);

  return (
    <AddressLabelProvider {...labelResolver}>
      <AddressSuggestionProvider {...suggestionResolver}>
        <YourApp />
      </AddressSuggestionProvider>
    </AddressLabelProvider>
  );
}

Once mounted:

  • Every AddressDisplay in the subtree automatically shows the saved alias instead of the raw address.
  • Every AddressField shows autocomplete suggestions as the user types.

AddressBookWidget

Wire AddressBookWidget using the useAddressBookWidgetProps hook, which returns all the props the widget needs:

import { AddressBookWidget } from '@openzeppelin/ui-renderer';
import { createDexieDatabase, ALIAS_SCHEMA, useAddressBookWidgetProps } from '@openzeppelin/ui-storage';
import Dexie from 'dexie';

const db = createDexieDatabase(new Dexie('my-app'), ALIAS_SCHEMA);

function AddressBook({ addressing }) {
  const widgetProps = useAddressBookWidgetProps(db, { networkId: 'ethereum-mainnet' });

  return (
    <AddressBookWidget
      {...widgetProps}
      addressing={addressing}
    />
  );
}

See Components (AddressBookWidget) for a screenshot.

Custom Entity Stores

Extend EntityStorage to create typed stores for your own domain objects. The constructor takes a Dexie database instance and a table name:

import { EntityStorage, createDexieDatabase } from '@openzeppelin/ui-storage';
import Dexie from 'dexie';

interface SavedContract {
  id: string;
  address: string;
  name: string;
  networkId: string;
  addedAt: number;
}

const MY_SCHEMA = { contracts: '++id, address, networkId' };
const db = createDexieDatabase(new Dexie('my-app'), MY_SCHEMA);

class ContractStore extends EntityStorage<SavedContract> {
  constructor() {
    super(db, 'contracts');
  }

  async findByNetwork(networkId: string): Promise<SavedContract[]> {
    return this.query((item) => item.networkId === networkId);
  }
}

const contractStore = new ContractStore();
await contractStore.add({ id: '...', address: '0x...', name: 'My Token', networkId: 'ethereum-mainnet', addedAt: Date.now() });

Key-Value Store

Use KeyValueStorage for simple settings and flags. Like EntityStorage, it takes a Dexie db instance and a table name:

import { KeyValueStorage, createDexieDatabase } from '@openzeppelin/ui-storage';
import Dexie from 'dexie';

const MY_SCHEMA = { settings: 'key' };
const db = createDexieDatabase(new Dexie('my-app'), MY_SCHEMA);

class AppSettings extends KeyValueStorage<string> {
  constructor() {
    super(db, 'settings');
  }
}

const settings = new AppSettings();
await settings.set('theme', 'dark');
const theme = await settings.get('theme'); // 'dark'

React Hook Factories

The storage package ships a set of factory functions that turn any EntityStorage or KeyValueStorage into a fully reactive React hook, complete with live queries, CRUD wrappers, and file import/export. These are the recommended way to consume storage in React components.

createRepositoryHook

The main factory. It composes the lower-level factories below into a single hook that provides everything a component needs: live data, loading state, CRUD operations, and optional file I/O.

import { createRepositoryHook, createDexieDatabase, EntityStorage } from '@openzeppelin/ui-storage';
import Dexie from 'dexie';
import { toast } from 'sonner';

interface Bookmark { id: string; url: string; label: string; }

const SCHEMA = { bookmarks: '++id, url' };
const db = createDexieDatabase(new Dexie('my-app'), [{ version: 1, stores: SCHEMA }]);

class BookmarkStore extends EntityStorage<Bookmark> {
  constructor() { super(db, 'bookmarks'); }
  async exportJson() { return JSON.stringify(await this.getAll()); }
  async importJson(json: string) { /* parse and bulk-insert */ }
}

const bookmarkStore = new BookmarkStore();

const useBookmarks = createRepositoryHook<Bookmark, BookmarkStore>({
  db,
  tableName: 'bookmarks',
  repo: bookmarkStore,
  onError: (title, err) => toast.error(title),
  fileIO: {
    exportJson: () => bookmarkStore.exportJson(),
    importJson: (json) => bookmarkStore.importJson(json),
    filePrefix: 'bookmarks-backup',
  },
});

function BookmarkList() {
  const { records, isLoading, save, remove, exportAsFile, importFromFile } = useBookmarks();

  if (isLoading) return <p>Loading…</p>;

  return (
    <ul>
      {records?.map((b) => (
        <li key={b.id}>
          {b.label} <button onClick={() => remove(b.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

The hook returned by createRepositoryHook exposes:

PropertyTypeDescription
recordsT[] | undefinedLive query result; undefined while loading
isLoadingbooleantrue until the first query resolves
save(record) => Promise<string>Insert a new record
update(id, partial) => Promise<void>Patch an existing record
remove(id) => Promise<void>Delete by ID
clear() => Promise<void>Remove all records
exportAsFile(ids?) => Promise<void>Download records as a timestamped JSON file (only when fileIO is configured)
importFromFile(file) => Promise<string[]>Import from a JSON File (only when fileIO is configured)

Lower-Level Factories

createRepositoryHook is built from three smaller factories that can be used independently when you need finer-grained control:

FactoryPurpose
createLiveQueryHook(db, tableName, query?)Returns a hook that re-renders whenever the underlying Dexie table changes. Powered by useLiveQuery from dexie-react-hooks.
createCrudHook(repo, { onError? })Wraps a CrudRepository (anything with save, update, delete, clear) with unified error handling.
createJsonFileIO({ exportJson, importJson }, { filePrefix, onError? })Produces exportAsFile / importFromFile functions that handle Blob creation, download triggers, file reading, and JSON validation.

createLiveQueryHook

import { createLiveQueryHook, createDexieDatabase } from '@openzeppelin/ui-storage';

const useContracts = createLiveQueryHook<SavedContract>(db, 'contracts');

function ContractList() {
  const contracts = useContracts(); // undefined while loading, then T[]
  return <ul>{contracts?.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}

Pass an optional query function for filtered or sorted results:

const useRecentContracts = createLiveQueryHook<SavedContract>(
  db,
  'contracts',
  (table) => table.orderBy('addedAt').reverse().limit(10).toArray(),
);

createCrudHook

import { createCrudHook } from '@openzeppelin/ui-storage';

const useContractCrud = createCrudHook<SavedContract>(contractStore, {
  onError: (title, err) => console.error(title, err),
});

function AddButton() {
  const { save } = useContractCrud();
  return <button onClick={() => save({ url: '...', label: 'My Contract' })}>Add</button>;
}

createJsonFileIO

import { createJsonFileIO } from '@openzeppelin/ui-storage';

const { exportAsFile, importFromFile } = createJsonFileIO(
  { exportJson: () => store.exportJson(), importJson: (json) => store.importJson(json) },
  { filePrefix: 'my-data', onError: (title, err) => toast.error(title) },
);

// exportAsFile() triggers a browser download of "my-data-2026-04-09.json"
// importFromFile(file) reads a File, validates JSON, and calls importJson()

Next Steps