Skip to content

About

SSR-safe Redux middleware for LocalStorage persistence with hydration control

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

@laststance/redux-storage-middleware

npm version License: MIT TypeScript

SSR-safe Redux Toolkit middleware that persists slices to localStorage, AsyncStorage, or MMKV, with hydration control.

Highlights

  • 🔒 SSR-Safe: Works seamlessly with Next.js App Router and Server Components
  • 🎯 Selective Persistence: Choose which slices to persist with slices option
  • ⚡ Performance Optimized: Debounced/throttled writes, idle callback support
  • 📦 Simple API: Minimal configuration, maximum productivity
  • 🔄 Version Migration: Schema versioning with custom migration functions
  • 🧪 Battle-Tested: 160+ tests, high coverage, E2E verified with 5000+ items

Table of Contents


Installation

pnpm add @laststance/redux-storage-middleware

# Optional serializers
pnpm add superjson     # For Date/Map/Set support
pnpm add lz-string     # For compression

Peer Dependencies:

{
  "@reduxjs/toolkit": "^2.0.0",
  "react-redux": "^9.0.0"
}

Quick Start

import { combineReducers, configureStore } from '@reduxjs/toolkit'
import { createStorageMiddleware } from '@laststance/redux-storage-middleware'

interface AppState {
  emails: EmailsState
  settings: SettingsState
}

// Create root reducer
const rootReducer = combineReducers({
  emails: emailReducer,
  settings: settingsReducer,
})

// Create middleware, reducer, and API
const { middleware, reducer, api } = createStorageMiddleware<AppState>({
  rootReducer, // Required: pass your root reducer
  key: 'my-app-state',
  slices: ['emails', 'settings'],
})

// Configure store with returned reducer (already hydration-wrapped)
export const store = configureStore({
  reducer, // Use the returned reducer
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(middleware),
})

// Hydration happens automatically on client
// Export API for manual control
export { api as storageApi }

API Reference

createStorageMiddleware<S>(options)

Creates the storage middleware and returns both the middleware and a control API.

Configuration Options

Option Type Default Description
rootReducer Reducer<S, AnyAction> required Root reducer to wrap with hydration
key string required Storage key
slices (keyof S)[] undefined Slices this middleware is the only writer for
storage StateStorage createSafeLocalStorage() Sync or async backend. A custom instance skips the window check and can hydrate during SSR
serializer Serializer defaultJsonSerializer Custom serializer for state persistence
version number 0 Schema version — increment when state shape changes
migrate (state: Partial<S>, oldVersion: number) => Partial<S> undefined Migration function for version mismatches
merge (persisted: Partial<S>, current: S) => S shallow merge Custom merge strategy for hydration

Performance Options

Option Type Default Description
performance.debounceMs number 300 Debounce delay for saves. A kill inside this window drops the last write
performance.throttleMs number undefined Throttle interval (overrides debounce)
performance.useIdleCallback boolean false Use requestIdleCallback
performance.idleTimeout number 1000 Fallback timeout for idle callback

Lifecycle Callbacks

Callback Signature Description
onHydrationComplete (state: S) => void Called when hydration completes
onSaveComplete (state: S) => void Called after each save
onError (error, operation) => void Error handler for load/save/clear

Returns

interface StorageMiddlewareResult<S> {
  middleware: Middleware<object, S> // Redux middleware
  reducer: Reducer<S, AnyAction> // Hydration-wrapped reducer (use this in configureStore)
  api: HydrationApi<S> // Control API
}

Hydration API

The api object returned from createStorageMiddleware provides control methods:

interface HydrationApi<T> {
  // State management
  rehydrate(): Promise<void> // Manual hydration trigger
  hasHydrated(): boolean // Check if hydration completed
  getHydrationState(): HydrationState // 'idle' | 'hydrating' | 'hydrated' | 'error'
  getHydratedState(): T | null // Access the hydrated state

  // Storage control
  clearStorage(): void // Remove persisted state

  // Callbacks (returns unsubscribe function)
  onHydrate(cb: (state: T) => void): () => void
  onFinishHydration(cb: (state: T) => void): () => void
}

Hydration States

State Description
idle Initial state, hydration not yet attempted
hydrating Hydration in progress
hydrated Hydration completed successfully
error Hydration failed (storage/parse error)

Storage Backends

Factory functions for different storage backends:

import {
  createSafeLocalStorage, // SSR-safe localStorage wrapper
  createSafeSessionStorage, // SSR-safe sessionStorage wrapper
  createNoopStorage, // No-op storage (SSR fallback)
  createMemoryStorage, // In-memory storage for testing
  toAsyncStorage, // Convert sync to async wrapper
} from '@laststance/redux-storage-middleware'

// Utility functions
import {
  isValidStorage, // Type guard for StateStorage
  getStorageSize, // Get item size in bytes
  getRemainingStorageQuota, // Estimate quota remaining
} from '@laststance/redux-storage-middleware'

loadStateFromStorage and clearStorageState read localStorage directly. They are web-only. React Native should wait for middleware hydration instead of calling them.

React Native

This package does not depend on react-native, AsyncStorage, or MMKV. Install the backend in the app:

npx expo install @react-native-async-storage/async-storage
# MMKV v4 only. v3 `delete` makes removeItem throw.
npx expo install react-native-mmkv react-native-nitro-modules

AsyncStorage and createMMKVStorage have no init step. initSuperJsonSerializer and initCompressedSerializer apply only to those serializers. Omitting storage uses createSafeLocalStorage(), which no-ops when window is missing, including React Native. Do not use isServer() as the hydration gate.

Hydration starts after the store is created. The AsyncStorage read is async. Render a loading state until api.hasHydrated() is true. onFinishHydration also runs when the read fails; check api.getHydrationState() === 'hydrated' before trusting restored state. A dispatch during the read is overwritten when persisted state is merged. Do not persist a slice that a database also owns.

import AsyncStorage from '@react-native-async-storage/async-storage'
import { combineReducers, configureStore, createSlice } from '@reduxjs/toolkit'
import { createMMKV } from 'react-native-mmkv'
import {
  createMMKVStorage,
  createStorageMiddleware,
} from '@laststance/redux-storage-middleware'

const notesSlice = createSlice({
  name: 'notes',
  initialState: { items: [] as string[] },
  reducers: {},
})
const rootReducer = combineReducers({ notes: notesSlice.reducer })

const { middleware, reducer, api } = createStorageMiddleware({
  rootReducer,
  key: 'field-notes',
  slices: ['notes'],
  storage: AsyncStorage,
})

export const store = configureStore({
  reducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(middleware),
})

export const storageApi = api

// MMKV v4: getString / set / remove. Missing keys become null.
const mmkv = createMMKVStorage(createMMKV())
const mmkvPersistence = createStorageMiddleware({
  rootReducer,
  key: 'field-notes',
  slices: ['notes'],
  storage: mmkv,
})

Serializers

JSON Serializer (Default)

import {
  createJsonSerializer, // Basic JSON serializer
  createEnhancedJsonSerializer, // With Date/Map/Set support
  defaultJsonSerializer, // Default instance
} from '@laststance/redux-storage-middleware'

const serializer = createJsonSerializer({
  replacer: (key, value) => value, // Custom replacer
  reviver: (key, value) => value, // Custom reviver
  space: 2, // Indent for debugging
})

SuperJSON Serializer

Handles Date, Map, Set, undefined, BigInt automatically.

import {
  initSuperJsonSerializer,
  createSuperJsonSerializer,
  isSuperJsonLoaded,
} from '@laststance/redux-storage-middleware'

// Initialize once at app startup
await initSuperJsonSerializer()

// Create serializer
const serializer = createSuperJsonSerializer<AppState>()

Compressed Serializer

LZ-String based compression for large state.

import {
  initCompressedSerializer,
  createCompressedSerializer,
  isLZStringLoaded,
  getCompressionRatio,
} from '@laststance/redux-storage-middleware'

await initCompressedSerializer()

const serializer = createCompressedSerializer<AppState>({
  format: 'utf16' | 'base64' | 'uri', // default: 'utf16'
})

Advanced Usage

SSR Integration

// Next.js App Router pattern
'use client'

import { useEffect, useState } from 'react'
import { Provider } from 'react-redux'
import { store, storageApi } from './store'

export function StoreProvider({ children }) {
  const [hydrated, setHydrated] = useState(false)

  useEffect(() => {
    // Subscribe to hydration completion
    const unsubscribe = storageApi.onFinishHydration(() => {
      setHydrated(true)
    })

    // Check if already hydrated
    if (storageApi.hasHydrated()) {
      setHydrated(true)
    }

    return unsubscribe
  }, [])

  if (!hydrated) {
    return <LoadingSpinner />
  }

  // Do not dispatch before onFinishHydration. Persisted state wins the merge,
  // so an action during the read is overwritten when hydration finishes.

  return <Provider store={store}>{children}</Provider>
}

Version Migration

When your state shape changes between releases, use version and migrate to handle the transition:

const { middleware, reducer, api } = createStorageMiddleware<AppState>({
  rootReducer,
  key: 'my-app-state',
  slices: ['settings'],
  version: 2, // Increment when state shape changes
  migrate: (state, oldVersion) => {
    if (oldVersion < 1) {
      // v0 → v1: added theme field
      state.settings = { ...state.settings, theme: 'system' }
    }
    if (oldVersion < 2) {
      // v1 → v2: renamed 'lang' to 'locale'
      const { lang, ...rest } = state.settings as any
      state.settings = { ...rest, locale: lang ?? 'en' }
    }
    return state
  },
})

Behavior:

Scenario What happens
Stored version matches config Normal hydration, no migration
Version mismatch + migrate provided Runs migration, saves migrated state to storage
Version mismatch + no migrate Clears storage (safe default), uses initial state
Migration throws an error Clears storage, calls onError callback
Legacy data without version field Treated as version 0

Custom Storage Backend

import {
  createSafeSessionStorage,
  createMemoryStorage,
} from '@laststance/redux-storage-middleware'

// Use sessionStorage instead of localStorage
const { middleware, reducer, api } = createStorageMiddleware<AppState>({
  rootReducer,
  key: 'my-app-state',
  storage: createSafeSessionStorage(),
})

// Use in-memory storage for testing
const { middleware, reducer, api } = createStorageMiddleware<AppState>({
  rootReducer,
  key: 'test-state',
  storage: createMemoryStorage(),
})

Custom Merge Strategy

import { deepMerge } from '@laststance/redux-storage-middleware'

// Deep merge for nested state (default is shallow merge)
const { middleware, reducer, api } = createStorageMiddleware<AppState>({
  rootReducer,
  key: 'my-app-state',
  merge: deepMerge,
})

// Custom merge: keep some fields from current state
const { middleware, reducer, api } = createStorageMiddleware<AppState>({
  rootReducer,
  key: 'my-app-state',
  merge: (persisted, current) => ({
    ...current,
    ...persisted,
    settings: { ...current.settings, ...persisted.settings },
  }),
})

Performance

Benchmark Results (1000 emails)

Metric Value
JSON.stringify 0.32ms
JSON.parse 0.41ms
Storage Size ~460KB
Full Round-trip 0.74ms
Debounce Overhead <0.001ms

10 Optimization Approaches

We benchmarked 10 different localStorage optimization strategies:

Approach Write (ms) Read (ms) Size (KB) Notes
1. Native JSON 0.39 0.38 921 Baseline - fast, no type preservation
2. Type Preservation 1.48 1.47 921 Preserves Date, Map, Set
3. Compression 1.12 1.28 1812 Base64 overhead increases size
4. Selective Slices 0.31 0.38 921 Only persist critical slices
5. Debounced Writes 3.06 0.40 461 10 changes → 1 write
6. Throttled Writes 1.56 0.42 461 Rate-limited writes
7. Differential Updates 0.64 0.39 0.1 Only store changed portions
8. Chunked Storage 0.34 0.42 921 Split into chunks
9. Minimal Serialization 1.17 0.45 845 Short keys (~8% smaller)
10. Lazy Hydration 0.33 0.001 0.1 Meta-first, full data on demand

Large Dataset Performance (5000 emails)

Metric Value
Storage Size ~4.5MB
Write Time ~2ms
Read Time ~2ms
Hydration (E2E) ~500ms

Recommendations

Use Case Recommended Approach
Most apps Debounced writes (default 300ms) - reduces writes by 10x
Large datasets Lazy hydration - near-instant initial load
Frequent updates Differential updates - minimal data transfer
Type-rich data Type preservation (SuperJSON) if you need Date/Map/Set

Running Benchmarks

# Core benchmarks
npx tsx benchmarks/benchmark.ts

# 10 optimization approaches
npx tsx benchmarks/optimization-approaches.ts

Testing

Unit Tests (Vitest)

pnpm test           # Watch mode
pnpm test:run       # Single run
pnpm test:coverage  # With coverage

Coverage: 188 tests with 80%+ coverage across 11 test files:

Category Tests Coverage
Core Middleware 38 Initialization, hydration, callbacks
React Native 22 Thenable storage, MMKV adapter, clear/rehydrate
Storage Layer 29 localStorage, memory, async wrappers, no window
JSON Serializer 19 Basic, enhanced, replacers/revivers
SuperJSON 8 Async init, type handling, errors
Compressed 12 LZ-String, formats, compression
Utilities 28 Debounce, throttle, idle, SSR detection
Package Exports 32 Public API validation

E2E Tests (Playwright)

pnpm build && pnpm test:e2e

24 tests including:

  • 1000+ email load testing (<5s requirement)
  • localStorage persistence verification
  • Page reload hydration (<3s requirement)
  • Debounce optimization verification

Examples

Gmail Clone Demo

A production-grade demo showing 5000+ email persistence:

Location: examples/gmail-clone

Stack:

  • Next.js 14 (App Router)
  • Redux Toolkit 2.11
  • shadcn/ui + Tailwind CSS
  • Playwright E2E tests

Features:

  • Real localStorage persistence
  • Hydration status indicator
  • Search & filter functionality
  • Performance metrics display

Configuration:

const { middleware, reducer, api } = createStorageMiddleware<AppState>({
  rootReducer, // Pass your root reducer
  key: 'gmail-clone-state',
  slices: ['emails'],
  performance: {
    debounceMs: 300,
    useIdleCallback: false, // For E2E predictability
  },
  onHydrationComplete: (state) => {
    console.log('Hydrated:', state.emails?.emails?.length)
  },
})

Run the demo:

cd examples/gmail-clone
pnpm dev

Field Notes

An Expo app that persists notes and theme through AsyncStorage. Playwright covers reload, delete, theme, and clear on the web export.

Location: examples/field-notes


TypeScript Support

Full TypeScript support with generic state typing:

// State type inference
const { middleware, reducer, api } = createStorageMiddleware<RootState>({
  rootReducer, // Required: pass your root reducer
  key: 'app',
  slices: ['user', 'settings'], // Type-checked against RootState keys
})

// Hydration API is typed
const state: RootState | null = api.getHydratedState()

Action Types

import {
  ACTION_HYDRATE_START,
  ACTION_HYDRATE_COMPLETE,
  ACTION_HYDRATE_ERROR,
  type StorageMiddlewareAction,
} from '@laststance/redux-storage-middleware'

Note: The returned reducer from createStorageMiddleware() is already hydration-wrapped. No manual wrapper is needed.


Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Write tests for new functionality
  4. Ensure all tests pass: pnpm test:run
  5. Run linting: pnpm lint
  6. Submit a pull request

Development

# Install dependencies
pnpm install

# Run tests in watch mode
pnpm test

# Type checking
pnpm typecheck

# Build
pnpm build

# Run Gmail Clone example
cd examples/gmail-clone && pnpm dev

License

MIT © Laststance.io


Related

About

SSR-safe Redux middleware for LocalStorage persistence with hydration control

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages