Skip to content

Persistence Plugin

The persistence plugin automatically saves state to IndexedDB and restores it when instances are created. It is a plugin like any other — installed once at startup — but with one extra step: you tell it which classes to persist by calling .persist(). Everything else (saving on change, hydrating on create, status tracking) happens through the registry lifecycle, driving the same hydration API that any plugin can use.

Terminal window
pnpm add @blac/plugin-persist
import { createIndexedDbPersistPlugin } from '@blac/plugin-persist';
import { getPluginManager } from '@blac/core';
const persist = createIndexedDbPersistPlugin();
persist.persist(UserSettingsCubit);
getPluginManager().install(persist);

That’s it. UserSettingsCubit state is now saved to IndexedDB on every change and restored when the instance is created.

createIndexedDbPersistPlugin({
databaseName: 'my-app', // default: 'blac-persist'
storeName: 'app-state', // default: 'blac-state'
pluginName: 'my-persist', // default: 'indexeddb-persist'
adapter: customAdapter, // custom storage adapter
});

Call .persist() for each class you want to persist. The method is chainable.

persist
.persist(UserSettingsCubit)
.persist(CartCubit, { debounceMs: 500 })
.persist(ThemeCubit);
persist.persist(CartCubit, {
key: 'cart', // custom storage key (default: ClassName:instanceId)
debounceMs: 500, // debounce saves (default: 0)
stateToDb: (state) => state.items, // transform before saving
dbToState: (payload) => ({ items: payload }), // transform on load
onHydrated: (state, ctx) => {
// called after restore
console.log('Cart restored with', state.items.length, 'items');
},
onError: (error, ctx) => {
// called on save or load error
console.error('Persist error:', error);
},
});
OptionTypeDefaultDescription
keystring | (ctx) => stringClassName:instanceIdStorage key
debounceMsnumber0Debounce save operations
stateToDb(state, ctx) => TPayloadIdentityTransform state before saving
dbToState(payload, ctx) => SIdentityTransform persisted data back to state
onHydrated(state, ctx) => voidCalled after successful hydration
onError(error, ctx) => voidCalled on error

Use a function for per-instance storage keys:

persist.persist(EditorCubit, {
key: (ctx) => `editor:${ctx.instanceId}`,
});

When a persisted instance is created:

idle → hydrating → hydrated
→ error (if load fails)

After hydration, on state changes:

hydrated → saving → saved
→ error (if save fails)

If state changes before hydration completes (e.g., the user interacts with the component immediately), the persisted state is discarded. The user’s changes take priority — stale data from disk should never clobber something the user just did.

You can detect this via the changedWhileHydrating field on the hydrationChanged system event. That event is the other side of this same mechanism: the persistence plugin reads the flag to decide whether to apply the loaded state, and you can observe the same flag to react in your own code.

const status = persist.getStatus(myInstance);
// { key, className, instanceId, phase, hydratedFromStorage, savedAt?, error? }

Phases: 'idle' | 'hydrating' | 'hydrated' | 'saving' | 'saved' | 'error'

const unsub = persist.subscribe((event) => {
// event: { instance, status }
console.log(event.status.phase);
});
await persist.clearRecord('cart'); // clear one record
await persist.clearAll(); // clear everything

Implement the IndexedDbPersistAdapter interface to use a different storage backend:

import {
createIndexedDbPersistPlugin,
type IndexedDbPersistAdapter,
type PersistedRecord,
} from '@blac/plugin-persist';
// A simple in-memory backend (handy for tests or non-IndexedDB environments).
const store = new Map<string, PersistedRecord>();
const memoryAdapter: IndexedDbPersistAdapter = {
isAvailable: () => true,
get: async (key) => store.get(key) ?? null,
put: async (record) => {
store.set(record.id, record);
},
delete: async (key) => {
store.delete(key);
},
clear: async () => {
store.clear();
},
};
const persist = createIndexedDbPersistPlugin({ adapter: memoryAdapter });

This is useful for testing or for environments where IndexedDB is not available.

$blac.hydration.wait() is available on every state container, not something this plugin adds — it resolves once hydration settles (or immediately, if the bloc was never set up to hydrate). That makes it safe to call even on blocs you have not registered with .persist(). Use it in your Cubit to defer work until restored state is in place:

class AuthCubit extends Cubit<AuthState> {
constructor() {
super({ user: null, token: null });
}
async initialize() {
await this.$blac.hydration.wait();
if (this.state.token) {
await this.refreshSession();
}
}
}

The plugin automatically checks IndexedDB availability on install and disables itself with a warning if unavailable.

  • Plugin Overview — the plugin catalog and the install API
  • System Events — the hydrationChanged event behind the dirty-state logic
  • Cubit$blac.hydration.status, $blac.hydration.isHydrated, and $blac.hydration.wait() on the container
  • Patterns — hydration-aware loading recipes
  • Glossary — definitions for hydration, plugin, and registry