Skip to content

persist()

Middleware for persisting store state to storage (localStorage, IndexedDB, etc.).

Signature

ts
function persist(options: PersistOptions): StoreMiddleware;

Options

ts
interface PersistContext extends StoreMiddlewareContext {
  store: StoreInstance; // The created store instance
}

interface PersistHandler {
  load?: () => unknown | Promise<unknown>;
  save?: (state: Record<string, unknown>) => void;
}

interface PersistOptions {
  // Only persist stores/fields with `persisted` meta (default: false)
  persistedOnly?: boolean;

  // Filter which stores to persist (called after persistedOnly)
  filter?: (context: PersistContext) => boolean;

  // Filter which fields to persist (for multi-storage patterns)
  fields?: (context: PersistContext) => string[];

  // Handler factory - creates load/save for each store
  handler: (
    context: PersistContext
  ) => PersistHandler | Promise<PersistHandler>;

  // Handle errors during init, load, or save
  onError?: (error: unknown, operation: "init" | "load" | "save") => void;

  // Force overwrite dirty state during hydration
  force?: boolean;
}

The PersistContext provides access to:

  • spec - The store specification
  • meta - MetaQuery for querying store metadata
  • displayName - The store's display name
  • store - The created store instance

Design Philosophy

Storion's persist takes a minimal, composable approach compared to other state management libraries. It provides the essential building blocks while letting you control the implementation details.

What persist Does

FeatureDescription
Load on initCalls your load() when a store is created
Save on changeCalls your save() whenever store state changes
Field filteringFilter which fields to persist via fields option
Store filteringSkip stores via filter option or notPersisted meta
Opt-in modeOnly persist persisted-marked stores via persistedOnly
Meta integrationQuery store metadata for conditional persistence
Async initHandler can be async (for IndexedDB, remote storage, etc.)
Error handlingUnified onError callback for all operations
Force hydrationOption to overwrite "dirty" state during load

What You Control

FeatureYour ResponsibilityWhy
Storage engineImplement in handlerYou choose: localStorage, IndexedDB, remote API, etc.
SerializationImplement in load/saveYou control: JSON, superjson, custom transforms
DebouncingWrap save with debounceDifferent stores may need different delays
ThrottlingWrap save with throttleControl save frequency per your needs
Purge/ClearCall storage APIs directlyNo hidden state to manage
FlushNot neededSaves are synchronous to your handler
MigrationsTransform in loadYou know your schema best
EncryptionImplement in load/saveSecurity requirements vary

Comparison with Other Libraries

Redux Persist

ts
// Redux Persist - declarative config with built-in features
import { persistStore, persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage";

const persistConfig = {
  key: "root",
  storage,
  whitelist: ["user", "settings"],
  blacklist: ["temp"],
  transforms: [encryptTransform],
  migrate: createMigrate(migrations),
  throttle: 1000,
  // Many more options...
};

const persistedReducer = persistReducer(persistConfig, rootReducer);
const persistor = persistStore(store);

// To purge: persistor.purge()
// To flush: persistor.flush()

Redux Persist provides:

  • Built-in storage engines (localStorage, AsyncStorage, etc.)
  • Transforms pipeline for serialization
  • Migration system with versioning
  • Throttling built-in
  • persistor object for purge/flush/pause

Zustand Persist

ts
// Zustand - middleware wrapping with options
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";

const useStore = create(
  persist(
    (set) => ({
      count: 0,
      increment: () => set((s) => ({ count: s.count + 1 })),
    }),
    {
      name: "counter",
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({ count: state.count }),
      onRehydrateStorage: () => (state, error) => {
        /* ... */
      },
      version: 1,
      migrate: (persisted, version) => {
        /* ... */
      },
      // skipHydration, merge options...
    }
  )
);

// Access: useStore.persist.clearStorage()
// Access: useStore.persist.rehydrate()

Zustand Persist provides:

  • Storage abstraction with createJSONStorage
  • partialize for field selection
  • Migration with versioning
  • Rehydration callbacks
  • Methods on store for clear/rehydrate

Storion Persist

ts
// Storion - handler pattern with full control
import { container, forStores, meta } from 'storion';
import { persist, notPersisted } from 'storion/persist';
import { debounce } from 'lodash-es';

const inSession = meta();  // Fields to persist in sessionStorage

const userStore = store({
  name: 'user',
  state: { name: '', token: '', temp: '' },
  meta: meta.of(
    inSession.for(['name', 'token']),
    notPersisted.for('temp'),
  ),
  setup: /* ... */,
});

const app = container({
  middleware: forStores([
    persist({
      filter: ({ meta }) => meta.any(inSession),
      fields: ({ meta }) => meta.fields(inSession),
      handler: (ctx) => {
        const key = `app:${ctx.displayName}`;
        const debouncedSave = debounce(
          (s) => localStorage.setItem(key, JSON.stringify(s)),
          300
        );
        return {
          load: () => {
            const data = localStorage.getItem(key);
            if (!data) return null;
            // Migration logic here if needed
            return JSON.parse(data);
          },
          save: debouncedSave,
        };
      },
      onError: (err, op) => console.error(`${op} failed:`, err),
    }),
  ]),
});

// To purge: localStorage.removeItem('app:user')
// To clear all: localStorage.clear() or iterate keys

Summary

FeatureRedux PersistZustand PersistStorion Persist
PhilosophyFeature-richBalancedMinimal core
Storage enginesBuilt-in adapterscreateJSONStorageYou implement
Debounce/ThrottleBuilt-in throttleNot built-inYou implement
MigrationsBuilt-in systemBuilt-in migrateYou implement
Field selectionwhitelist/blacklistpartializefields + meta
Purge/Flushpersistor methodspersist methodsDirect storage calls
Async storageAsyncStorage adapterasync getItem/setItemAsync handler
Bundle size impactLargerMediumMinimal
Learning curveHigherMediumLower
FlexibilityConfiguredModerateMaximum

Design Philosophy

Storion's persist middleware follows the "minimal core, maximum flexibility" principle:

  • No built-in storage adapters — You implement load/save, giving full control over storage engine, encryption, compression
  • No built-in debounce — You add debounce in your handler, choosing the right timing per store
  • No built-in migrations — You handle migrations in load, with full access to raw data
  • Direct storage access — Purge/flush via direct storage calls (e.g., localStorage.removeItem)

This means slightly more code to write, but no fighting the abstraction when you need custom behavior.

Basic Example

ts
import { container, forStores } from "storion";
import { persist } from "storion/persist";

const app = container({
  middleware: forStores([
    persist({
      handler: (ctx) => {
        const key = `app:${ctx.displayName}`;
        return {
          load: () => {
            const data = localStorage.getItem(key);
            return data ? JSON.parse(data) : null;
          },
          save: (state) => {
            localStorage.setItem(key, JSON.stringify(state));
          },
        };
      },
    }),
  ]),
});

Filtering Stores

Only persist specific stores:

ts
persist({
  filter: (ctx) => ctx.displayName === "user" || ctx.displayName === "settings",
  handler: (ctx) => {
    const key = `app:${ctx.displayName}`;
    return {
      load: () => JSON.parse(localStorage.getItem(key) || "null"),
      save: (state) => localStorage.setItem(key, JSON.stringify(state)),
    };
  },
});

Or use the applyFor helper:

ts
import { applyFor } from "storion";

container({
  middleware: [
    applyFor(
      "user",
      persist({
        handler: (ctx) => ({
          load: () => JSON.parse(localStorage.getItem("user") || "null"),
          save: (state) => localStorage.setItem("user", JSON.stringify(state)),
        }),
      })
    ),
  ],
});

Async Handler (IndexedDB)

The handler can be async for initialization that requires async setup:

ts
import { openDB } from "idb";

persist({
  handler: async (ctx) => {
    // Async initialization - opens DB once per store
    const db = await openDB("app-db", 1, {
      upgrade(db) {
        db.createObjectStore("stores");
      },
    });

    return {
      load: () => db.get("stores", ctx.displayName),
      save: (state) => db.put("stores", state, ctx.displayName),
    };
  },
});

Using notPersisted Meta

Exclude stores or fields from persistence:

ts
import { store } from 'storion';
import { notPersisted } from 'storion/persist';

// Exclude entire store
const sessionStore = store({
  name: 'session',
  state: { token: '', expiry: 0 },
  meta: notPersisted(),  // Won't be persisted
  setup: /* ... */,
});

// Exclude specific fields
const userStore = store({
  name: 'user',
  state: {
    name: '',
    email: '',
    password: '',         // Sensitive
    confirmPassword: '',  // Temporary
  },
  meta: notPersisted.for(['password', 'confirmPassword']),
  setup: /* ... */,
});

Opt-In Persistence with persistedOnly

By default, all stores are persisted. Use persistedOnly: true to only persist stores/fields explicitly marked with persisted meta:

ts
import { store, container, forStores } from 'storion';
import { persist, persisted, notPersisted } from 'storion/persist';

// Store-level: entire store persisted
const userStore = store({
  name: 'user',
  state: { name: '', email: '', avatar: '' },
  meta: persisted(),  // All fields persisted
  setup: () => ({}),
});

// Field-level: only specific fields persisted
const settingsStore = store({
  name: 'settings',
  state: { theme: 'light', fontSize: 14, cache: {} },
  meta: persisted.for(['theme', 'fontSize']),  // Only these fields
  setup: () => ({}),
});

// No meta: store NOT persisted when persistedOnly: true
const tempStore = store({
  name: 'temp',
  state: { data: null },
  setup: () => ({}),
});

const app = container({
  middleware: forStores([
    persist({
      persistedOnly: true,  // Only persist marked stores/fields
      handler: (ctx) => {
        const key = `app:${ctx.displayName}`;
        return {
          load: () => JSON.parse(localStorage.getItem(key) || 'null'),
          save: (state) => localStorage.setItem(key, JSON.stringify(state)),
        };
      },
    }),
  ]),
});

Filtering Priority

When persistedOnly: true, filtering happens in this order:

  1. notPersisted (top priority) - Always excludes, even if persisted is present
  2. persistedOnly - Skips stores without any persisted meta
  3. filter option - Your custom filter function
ts
// notPersisted always wins
const conflictingStore = store({
  name: 'conflict',
  state: { data: '' },
  meta: meta.of(
    persisted(),       // Wants to persist
    notPersisted(),    // But this wins - store is skipped
  ),
});

// Field-level priority
const mixedStore = store({
  name: 'mixed',
  state: { name: '', password: '', token: '' },
  meta: meta.of(
    persisted(),                           // All fields persisted
    notPersisted.for(['password', 'token']), // Except these
  ),
});
// Only 'name' is persisted

When to Use persistedOnly

ScenarioUse persistedOnly: true
Large app with many stores, few need persistence✅ Yes
Most stores need persistence, few exceptions❌ No, use notPersisted
Explicit opt-in for security/compliance✅ Yes
Simple app with few stores❌ No, default is simpler

Error Handling

ts
persist({
  handler: (ctx) => ({
    load: () => /* ... */,
    save: (state) => /* ... */,
  }),
  onError: (error, operation) => {
    console.error(`Persist ${operation} failed:`, error);

    if (operation === 'init') {
      // Handler initialization failed (e.g., DB connection)
    } else if (operation === 'load') {
      // Loading persisted state failed
    } else {
      // Saving state failed
    }
  },
})

Force Hydration

By default, hydration skips "dirty" properties (modified since initialization). Use force: true to always overwrite:

ts
persist({
  handler: (ctx) => ({
    load: () => /* ... */,
    save: (state) => /* ... */,
  }),
  force: true,  // Always use persisted values
})

Debouncing Saves

Implement debouncing in the handler closure:

ts
import { debounce } from "lodash-es";

persist({
  handler: (ctx) => {
    const key = `app:${ctx.displayName}`;

    // Debounced save - created once per store
    const debouncedSave = debounce(
      (state: unknown) => localStorage.setItem(key, JSON.stringify(state)),
      300
    );

    return {
      load: () => JSON.parse(localStorage.getItem(key) || "null"),
      save: debouncedSave,
    };
  },
});

Multi-Storage Patterns

Use the fields option combined with custom meta types to split store state across different storage backends:

ts
import { store, container, forStores, meta } from "storion";
import { persist } from "storion/persist";

// Define meta types for different storage targets
const inSession = meta(); // Fields for sessionStorage
const inLocal = meta(); // Fields for localStorage

// Store with fields split between storage types
const authStore = store({
  name: "auth",
  state: {
    accessToken: "", // Expires with browser session
    refreshToken: "", // Persists across sessions
    userId: "", // Persists across sessions
    lastActivity: 0, // Track for this session only
  },
  setup: ({ state }) => ({
    setTokens: (access: string, refresh: string) => {
      state.accessToken = access;
      state.refreshToken = refresh;
    },
    setUserId: (id: string) => {
      state.userId = id;
    },
    updateActivity: () => {
      state.lastActivity = Date.now();
    },
  }),
  meta: meta.of(
    inSession.for(["accessToken", "lastActivity"]),
    inLocal.for(["refreshToken", "userId"]),
  ),
});

// Session storage middleware
const sessionMiddleware = persist({
  filter: ({ meta }) => meta.any(inSession),
  fields: ({ meta }) => meta.fields(inSession),
  handler: (ctx) => {
    const key = `session:${ctx.displayName}`;
    return {
      load: () => JSON.parse(sessionStorage.getItem(key) || "null"),
      save: (state) => sessionStorage.setItem(key, JSON.stringify(state)),
    };
  },
});

// Local storage middleware
const localMiddleware = persist({
  filter: ({ meta }) => meta.any(inLocal),
  fields: ({ meta }) => meta.fields(inLocal),
  handler: (ctx) => {
    const key = `local:${ctx.displayName}`;
    return {
      load: () => JSON.parse(localStorage.getItem(key) || "null"),
      save: (state) => localStorage.setItem(key, JSON.stringify(state)),
    };
  },
});

// Apply both middlewares
const app = container({
  middleware: forStores([sessionMiddleware, localMiddleware]),
});

This pattern enables:

  • Security: Store sensitive tokens in sessionStorage (cleared on browser close)
  • User experience: Keep refresh tokens in localStorage for seamless re-authentication
  • Flexibility: Each middleware only handles its designated fields
  • Composability: Same store can have fields persisted to different backends

Combining with notPersisted

The fields option works alongside notPersisted meta. Fields marked as notPersisted are excluded even if they match the fields filter:

ts
const mixedStore = store({
  name: "mixed",
  state: {
    publicData: "",
    sensitiveData: "",
  },
  meta: meta.of(
    inSession.for(["publicData", "sensitiveData"]),
    notPersisted.for("sensitiveData"), // Excluded despite being in inSession
  ),
});
// Only publicData will be persisted

See Also

Released under the MIT License.