Skip to content

Commit 9dfa323

Browse files
feat(redux-storage-middleware)!: require rootReducer and return hydrated reducer
BREAKING CHANGE: API signature changed for createStorageMiddleware() Before: const { middleware } = createStorageMiddleware({ name, slices }) const store = configureStore({ reducer: withHydration(rootReducer), // Easy to forget! middleware: (get) => get().concat(middleware), }) After: const { middleware, reducer } = createStorageMiddleware({ rootReducer, // Required name, slices, }) const store = configureStore({ reducer, // Already hydration-wrapped middleware: (get) => get().concat(middleware), }) Changes: - rootReducer is now required parameter - Returns { middleware, reducer, api } - reducer is pre-wrapped - Removed skipHydration option (auto-hydration always enabled) - withHydration() marked as @deprecated - Updated all tests (16 tests migrated) - Updated README with Migration Guide - Updated GitBox app and gmail-clone example
1 parent 0857f84 commit 9dfa323

File tree

7 files changed

+411
-258
lines changed

7 files changed

+411
-258
lines changed

lib/redux/store.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,7 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'
1616
import type { TypedUseSelectorHook } from 'react-redux'
1717
import { useDispatch, useSelector } from 'react-redux'
1818

19-
import {
20-
createStorageMiddleware,
21-
withHydration,
22-
} from '@gitbox/redux-storage-middleware'
19+
import { createStorageMiddleware } from '@gitbox/redux-storage-middleware'
2320
import authReducer from './slices/authSlice'
2421
import boardReducer from './slices/boardSlice'
2522
import draftReducer from './slices/draftSlice'
@@ -33,16 +30,18 @@ const rootReducer = combineReducers({
3330
settings: settingsReducer,
3431
})
3532

36-
// Storage middleware configuration
37-
const { middleware: storageMiddleware } = createStorageMiddleware({
38-
// Synchronize settings, board, and draft slices to LocalStorage
39-
name: 'gitbox-state',
40-
slices: ['settings', 'board', 'draft'],
41-
})
33+
// Storage middleware configuration with new API
34+
// Returns hydration-wrapped reducer automatically
35+
const { middleware: storageMiddleware, reducer: hydratedReducer } =
36+
createStorageMiddleware({
37+
rootReducer, // Required: pass root reducer
38+
name: 'gitbox-state',
39+
slices: ['settings', 'board', 'draft'], // Persist these slices to localStorage
40+
})
4241

4342
export const store = configureStore({
44-
// Wrap reducer with withHydration to handle localStorage state restoration
45-
reducer: withHydration(rootReducer),
43+
// Use returned reducer (already hydration-wrapped)
44+
reducer: hydratedReducer,
4645
middleware: (getDefaultMiddleware) =>
4746
getDefaultMiddleware({
4847
serializableCheck: {

packages/redux-storage-middleware/README.md

Lines changed: 106 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ SSR-safe Redux Toolkit middleware for localStorage persistence with selective sl
3838
- [Testing](#testing)
3939
- [Examples](#examples)
4040
- [TypeScript Support](#typescript-support)
41+
- [Migration Guide](#migration-guide)
4142
- [Contributing](#contributing)
4243
- [License](#license)
4344

@@ -67,31 +68,36 @@ pnpm add lz-string # For compression
6768
## Quick Start
6869

6970
```typescript
70-
import { configureStore } from '@reduxjs/toolkit'
71+
import { combineReducers, configureStore } from '@reduxjs/toolkit'
7172
import { createStorageMiddleware } from '@gitbox/redux-storage-middleware'
7273

7374
interface AppState {
7475
emails: EmailsState
7576
settings: SettingsState
7677
}
7778

78-
// Create middleware and API
79-
const { middleware, api } = createStorageMiddleware<AppState>({
79+
// Create root reducer
80+
const rootReducer = combineReducers({
81+
emails: emailReducer,
82+
settings: settingsReducer,
83+
})
84+
85+
// Create middleware, reducer, and API
86+
const { middleware, reducer, api } = createStorageMiddleware<AppState>({
87+
rootReducer, // Required: pass your root reducer
8088
name: 'my-app-state',
8189
slices: ['emails', 'settings'],
8290
version: 1,
8391
})
8492

85-
// Configure store
93+
// Configure store with returned reducer (already hydration-wrapped)
8694
export const store = configureStore({
87-
reducer: {
88-
emails: emailReducer,
89-
settings: settingsReducer,
90-
},
95+
reducer, // Use the returned reducer
9196
middleware: (getDefaultMiddleware) =>
9297
getDefaultMiddleware().concat(middleware),
9398
})
9499

100+
// Hydration happens automatically on client
95101
// Export API for manual control
96102
export { api as storageApi }
97103
```
@@ -106,18 +112,18 @@ Creates the storage middleware and returns both the middleware and a control API
106112

107113
#### Configuration Options
108114

109-
| Option | Type | Default | Description |
110-
| --------------- | --------------------------- | -------------- | ------------------------------------------------------ |
111-
| `name` | `string` | **required** | localStorage key name |
112-
| `slices` | `(keyof S)[]` | `undefined` | State slices to persist (all if undefined) |
113-
| `partialize` | `(state: S) => Partial<S>` | `undefined` | Custom state selector function |
114-
| `exclude` | `string[]` | `[]` | Dot-notation paths to exclude (e.g., `['auth.token']`) |
115-
| `version` | `number` | `0` | Storage version for migrations |
116-
| `skipHydration` | `boolean` | `false` | Skip automatic hydration on init |
117-
| `storage` | `StateStorage` | `localStorage` | Custom storage backend |
118-
| `serializer` | `Serializer<T>` | JSON | Custom serialization logic |
119-
| `merge` | `(persisted, current) => S` | shallow | Merge strategy for hydration |
120-
| `migrate` | `(state, version) => S` | identity | Version migration function |
115+
| Option | Type | Default | Description |
116+
| ------------- | --------------------------- | -------------- | ------------------------------------------------------ |
117+
| `rootReducer` | `Reducer<S, AnyAction>` | **required** | Root reducer to wrap with hydration handling |
118+
| `name` | `string` | **required** | localStorage key name |
119+
| `slices` | `(keyof S)[]` | `undefined` | State slices to persist (all if undefined) |
120+
| `partialize` | `(state: S) => Partial<S>` | `undefined` | Custom state selector function |
121+
| `exclude` | `string[]` | `[]` | Dot-notation paths to exclude (e.g., `['auth.token']`) |
122+
| `version` | `number` | `0` | Storage version for migrations |
123+
| `storage` | `StateStorage` | `localStorage` | Custom storage backend |
124+
| `serializer` | `Serializer<T>` | JSON | Custom serialization logic |
125+
| `merge` | `(persisted, current) => S` | shallow | Merge strategy for hydration |
126+
| `migrate` | `(state, version) => S` | identity | Version migration function |
121127

122128
#### Performance Options
123129

@@ -141,8 +147,9 @@ Creates the storage middleware and returns both the middleware and a control API
141147

142148
```typescript
143149
interface StorageMiddlewareResult<S> {
144-
middleware: Middleware<object, S>
145-
api: HydrationApi<S>
150+
middleware: Middleware<object, S> // Redux middleware
151+
reducer: Reducer<S, AnyAction> // Hydration-wrapped reducer (use this in configureStore)
152+
api: HydrationApi<S> // Control API
146153
}
147154
```
148155

@@ -265,7 +272,8 @@ const serializer = createCompressedSerializer<AppState>({
265272
### Version Migrations
266273

267274
```typescript
268-
const { middleware } = createStorageMiddleware<AppState>({
275+
const { middleware, reducer } = createStorageMiddleware<AppState>({
276+
rootReducer,
269277
name: 'my-app',
270278
slices: ['settings'],
271279
version: 2,
@@ -290,7 +298,8 @@ const { middleware } = createStorageMiddleware<AppState>({
290298
```typescript
291299
import { shallowMerge, deepMerge } from '@gitbox/redux-storage-middleware'
292300

293-
const { middleware } = createStorageMiddleware<AppState>({
301+
const { middleware, reducer } = createStorageMiddleware<AppState>({
302+
rootReducer,
294303
name: 'my-app',
295304
slices: ['emails'],
296305
merge: deepMerge, // or shallowMerge (default)
@@ -340,7 +349,8 @@ export function StoreProvider({ children }) {
340349
### Exclude Paths
341350

342351
```typescript
343-
const { middleware } = createStorageMiddleware<AppState>({
352+
const { middleware, reducer } = createStorageMiddleware<AppState>({
353+
rootReducer,
344354
name: 'my-app',
345355
exclude: [
346356
'auth.token', // Skip sensitive data
@@ -353,7 +363,8 @@ const { middleware } = createStorageMiddleware<AppState>({
353363
### Partialize Function
354364
355365
```typescript
356-
const { middleware } = createStorageMiddleware<AppState>({
366+
const { middleware, reducer } = createStorageMiddleware<AppState>({
367+
rootReducer,
357368
name: 'my-app',
358369
partialize: (state) => ({
359370
// Only persist specific nested data
@@ -488,7 +499,8 @@ A production-grade demo showing 5000+ email persistence:
488499
**Configuration:**
489500
490501
```typescript
491-
const { middleware, api } = createStorageMiddleware<AppState>({
502+
const { middleware, reducer, api } = createStorageMiddleware<AppState>({
503+
rootReducer, // Pass your root reducer
492504
name: 'gmail-clone-state',
493505
slices: ['emails'],
494506
version: 1,
@@ -517,7 +529,8 @@ Full TypeScript support with generic state typing:
517529
518530
```typescript
519531
// State type inference
520-
const { middleware, api } = createStorageMiddleware<RootState>({
532+
const { middleware, reducer, api } = createStorageMiddleware<RootState>({
533+
rootReducer, // Required: pass your root reducer
521534
name: 'app',
522535
slices: ['user', 'settings'], // Type-checked against RootState keys
523536
})
@@ -538,13 +551,75 @@ import {
538551
ACTION_HYDRATE_ERROR,
539552
type StorageMiddlewareAction,
540553
} from '@gitbox/redux-storage-middleware'
554+
```
555+
556+
> **Note:** `withHydration()` is deprecated. The returned `reducer` from `createStorageMiddleware()` is already hydration-wrapped.
557+
558+
---
559+
560+
## Migration Guide
561+
562+
### Upgrading from v0.1.x to v0.2.x
563+
564+
**Breaking Changes:**
541565
542-
// Use in reducers for hydration handling
543-
import { withHydration } from '@gitbox/redux-storage-middleware'
566+
1. `rootReducer` is now a required parameter
567+
2. `skipHydration` option has been removed (hydration is always enabled)
568+
3. Return type now includes `reducer` which must be used in `configureStore`
544569
545-
const enhancedReducer = withHydration(rootReducer)
570+
**Before (v0.1.x):**
571+
572+
```typescript
573+
import {
574+
createStorageMiddleware,
575+
withHydration,
576+
} from '@gitbox/redux-storage-middleware'
577+
578+
const rootReducer = combineReducers({
579+
settings: settingsReducer,
580+
board: boardReducer,
581+
})
582+
583+
const { middleware } = createStorageMiddleware({
584+
name: 'my-app-state',
585+
slices: ['settings'],
586+
skipHydration: false, // This option no longer exists
587+
})
588+
589+
const store = configureStore({
590+
reducer: withHydration(rootReducer), // Manual wrapper required
591+
middleware: (getDefault) => getDefault().concat(middleware),
592+
})
593+
```
594+
595+
**After (v0.2.x):**
596+
597+
```typescript
598+
import { createStorageMiddleware } from '@gitbox/redux-storage-middleware'
599+
600+
const rootReducer = combineReducers({
601+
settings: settingsReducer,
602+
board: boardReducer,
603+
})
604+
605+
const { middleware, reducer } = createStorageMiddleware({
606+
rootReducer, // Required: pass your root reducer
607+
name: 'my-app-state',
608+
slices: ['settings'],
609+
})
610+
611+
const store = configureStore({
612+
reducer, // Use returned reducer (already hydration-wrapped)
613+
middleware: (getDefault) => getDefault().concat(middleware),
614+
})
546615
```
547616
617+
**Key Benefits of the New API:**
618+
619+
- No more "silent failures" from forgetting to use `withHydration()`
620+
- Simpler, more intuitive API
621+
- Automatic hydration on client (no need to configure `skipHydration`)
622+
548623
---
549624
550625
## Contributing

packages/redux-storage-middleware/examples/gmail-clone/src/lib/store.ts

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Gmail Clone demonstration of localStorage persistence
55
*/
66

7-
import { configureStore } from '@reduxjs/toolkit'
7+
import { combineReducers, configureStore } from '@reduxjs/toolkit'
88
import { createStorageMiddleware } from '@gitbox/redux-storage-middleware'
99

1010
import emailReducer, { type EmailsState } from './features/emails/emailSlice'
@@ -16,36 +16,42 @@ interface AppState {
1616
emails: EmailsState
1717
}
1818

19-
// Create storage middleware with performance settings
20-
const { middleware: storageMiddleware, api: storageApi } =
21-
createStorageMiddleware<AppState>({
22-
name: 'gmail-clone-state',
23-
slices: ['emails'],
24-
version: 1,
25-
performance: {
26-
debounceMs: 300, // Debounce writes for performance
27-
useIdleCallback: false, // Disabled for E2E test predictability
28-
},
29-
skipHydration: false,
30-
onHydrationComplete: (state: AppState) => {
31-
console.log('[Gmail Clone] Hydrated from localStorage:', {
32-
emailCount: state.emails?.emails?.length ?? 0,
33-
})
34-
},
35-
onSaveComplete: (state: AppState) => {
36-
console.log('[Gmail Clone] Saved to localStorage:', {
37-
emailCount: state.emails?.emails?.length ?? 0,
38-
})
39-
},
40-
onError: (error: Error, operation: string) => {
41-
console.error(`[Gmail Clone] Storage ${operation} error:`, error)
42-
},
43-
})
19+
// Create root reducer
20+
const rootReducer = combineReducers({
21+
emails: emailReducer,
22+
})
4423

45-
export const store = configureStore({
46-
reducer: {
47-
emails: emailReducer,
24+
// Create storage middleware with new API (rootReducer is now required)
25+
const {
26+
middleware: storageMiddleware,
27+
reducer: hydratedReducer,
28+
api: storageApi,
29+
} = createStorageMiddleware<AppState>({
30+
rootReducer, // Required: pass root reducer
31+
name: 'gmail-clone-state',
32+
slices: ['emails'],
33+
version: 1,
34+
performance: {
35+
debounceMs: 300, // Debounce writes for performance
36+
useIdleCallback: false, // Disabled for E2E test predictability
37+
},
38+
onHydrationComplete: (state: AppState) => {
39+
console.log('[Gmail Clone] Hydrated from localStorage:', {
40+
emailCount: state.emails?.emails?.length ?? 0,
41+
})
42+
},
43+
onSaveComplete: (state: AppState) => {
44+
console.log('[Gmail Clone] Saved to localStorage:', {
45+
emailCount: state.emails?.emails?.length ?? 0,
46+
})
4847
},
48+
onError: (error: Error, operation: string) => {
49+
console.error(`[Gmail Clone] Storage ${operation} error:`, error)
50+
},
51+
})
52+
53+
export const store = configureStore({
54+
reducer: hydratedReducer, // Use returned reducer (already hydration-wrapped)
4955
middleware: (getDefaultMiddleware) =>
5056
getDefaultMiddleware({
5157
serializableCheck: {

packages/redux-storage-middleware/src/index.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,34 @@
11
/**
22
* @gitbox/redux-storage-middleware
33
*
4-
* Custom middleware to synchronize Redux state with LocalStorage
5-
* SSR-safe with version migration and hydration control support
4+
* SSR-safe Redux Toolkit middleware for localStorage persistence
5+
* Automatic hydration, version migration, and selective slice persistence
66
*
77
* @example
88
* ```ts
9-
* import { createStorageMiddleware, withHydration } from '@gitbox/redux-storage-middleware'
9+
* import { createStorageMiddleware } from '@gitbox/redux-storage-middleware'
10+
* import { combineReducers, configureStore } from '@reduxjs/toolkit'
1011
*
11-
* const { middleware, api } = createStorageMiddleware({
12+
* const rootReducer = combineReducers({
13+
* settings: settingsReducer,
14+
* board: boardReducer,
15+
* })
16+
*
17+
* const { middleware, reducer, api } = createStorageMiddleware({
18+
* rootReducer, // Required: pass your root reducer
1219
* name: 'my-app-state',
13-
* slices: ['settings', 'preferences'],
20+
* slices: ['settings'],
1421
* version: 1,
1522
* })
1623
*
1724
* const store = configureStore({
18-
* reducer: withHydration(rootReducer),
25+
* reducer, // Use the returned reducer (already hydration-wrapped)
1926
* middleware: (getDefaultMiddleware) =>
2027
* getDefaultMiddleware().concat(middleware),
2128
* })
2229
*
23-
* // Check hydration status
24-
* if (api.hasHydrated()) {
25-
* console.log('State restored from localStorage')
26-
* }
30+
* // Hydration happens automatically on client
31+
* // Check status with api.hasHydrated()
2732
* ```
2833
*/
2934

0 commit comments

Comments
 (0)