@laststance/redux-storage-middleware
v0.2.1
Published
SSR-safe Redux middleware for LocalStorage persistence with hydration control
Maintainers
Readme
@laststance/redux-storage-middleware
SSR-safe Redux Toolkit middleware for localStorage persistence with selective slice hydration and performance optimization.
Highlights
- 🔒 SSR-Safe: Works seamlessly with Next.js App Router and Server Components
- 🎯 Selective Persistence: Choose which slices to persist with
slicesoption - ⚡ Performance Optimized: Debounced/throttled writes, idle callback support
- 📦 Simple API: Minimal configuration, maximum productivity
- 🧪 Battle-Tested: 100+ tests, high coverage, E2E verified with 5000+ items
Table of Contents
- Installation
- Quick Start
- API Reference
- Advanced Usage
- Performance
- Testing
- Examples
- TypeScript Support
- Contributing
- License
Installation
pnpm add @laststance/redux-storage-middleware
# Optional serializers
pnpm add superjson # For Date/Map/Set support
pnpm add lz-string # For compressionPeer 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 | localStorage key |
| slices | (keyof S)[] | undefined | State slices to persist (all if undefined) |
Performance Options
| Option | Type | Default | Description |
| ----------------------------- | --------- | ----------- | -------------------------------------- |
| performance.debounceMs | number | 300 | Debounce delay for saves |
| 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'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 />
}
return <Provider store={store}>{children}</Provider>
}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.tsTesting
Unit Tests (Vitest)
pnpm test # Watch mode
pnpm test:run # Single run
pnpm test:coverage # With coverageCoverage: 145 tests with 80%+ coverage across 9 test files:
| Category | Tests | Coverage | | --------------------- | ----- | ------------------------------------ | | Core Middleware | 29 | Initialization, hydration, callbacks | | Storage Layer | 19 | localStorage, memory, async wrappers | | JSON Serializer | 18 | Basic, enhanced, replacers/revivers | | SuperJSON Serializer | 16 | Async init, type handling, errors | | Compressed Serializer | 19 | LZ-String, formats, compression | | Utilities | 24 | Debounce, throttle, SSR detection | | Package Exports | 14 | All public API validation |
E2E Tests (Playwright)
pnpm build && pnpm test:e2e24 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 devTypeScript 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
reducerfromcreateStorageMiddleware()is already hydration-wrapped. No manual wrapper is needed.
Contributing
- Fork the repository
- Create a feature branch
- Write tests for new functionality
- Ensure all tests pass:
pnpm test:run - Run linting:
pnpm lint - 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 devLicense
MIT © Laststance.io
Related
- Redux Toolkit
- redux-persist - Original inspiration
- zustand/persist - Middleware pattern reference
- jotai/atomWithStorage - SSR patterns
