SSR-safe Redux Toolkit middleware for localStorage persistence with selective slice hydration and performance optimization.
- 🔒 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
- Installation
- Quick Start
- API Reference
- Advanced Usage
- Performance
- Testing
- Examples
- TypeScript Support
- Contributing
- License
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"
}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 }Creates the storage middleware and returns both the middleware and a control API.
| 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) |
| 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 |
| 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 |
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
}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
}| State | Description |
|---|---|
idle |
Initial state, hydration not yet attempted |
hydrating |
Hydration in progress |
hydrated |
Hydration completed successfully |
error |
Hydration failed (storage/parse error) |
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'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
})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>()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'
})// 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>
}| Metric | Value |
|---|---|
| JSON.stringify | 0.32ms |
| JSON.parse | 0.41ms |
| Storage Size | ~460KB |
| Full Round-trip | 0.74ms |
| Debounce Overhead | <0.001ms |
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 |
| Metric | Value |
|---|---|
| Storage Size | ~4.5MB |
| Write Time | ~2ms |
| Read Time | ~2ms |
| Hydration (E2E) | ~500ms |
| 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 |
# Core benchmarks
npx tsx benchmarks/benchmark.ts
# 10 optimization approaches
npx tsx benchmarks/optimization-approaches.tspnpm 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 |
pnpm build && pnpm test:e2e24 tests including:
- 1000+ email load testing (<5s requirement)
- localStorage persistence verification
- Page reload hydration (<3s requirement)
- Debounce optimization verification
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 devFull 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()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.
- 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
# 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 devMIT © Laststance.io
- Redux Toolkit
- redux-persist - Original inspiration
- zustand/persist - Middleware pattern reference
- jotai/atomWithStorage - SSR patterns