diff --git a/README.md b/README.md
index 3b1bd39..51fa192 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-# RTK Persist [](https://opensource.org/licenses/MIT) 
+# RTK Persist
**`rtk-persist`** is a lightweight, zero-dependency library that enhances Redux Toolkit's state management by adding seamless, persistent storage. It allows specified slices or reducers of your Redux state to be saved to a storage medium of your choice (like `localStorage` or `AsyncStorage`) and rehydrated on app startup.
@@ -17,13 +17,23 @@ The library works by wrapping standard Redux Toolkit functions, adding persisten
## ✨ Features
* **Effortless Persistence**: Persist any Redux Toolkit slice or reducer with minimal configuration.
+
* **Asynchronous Rehydration**: Store creation is now asynchronous, ensuring that your app only renders after the state has been fully rehydrated.
+
* **Seamless Integration**: Designed as a drop-in replacement for RTK functions. Adding or removing persistence is as simple as changing an import.
+
* **React Redux Integration**: Comes with a `` and a `usePersistedStore` hook for easy integration with React applications.
+
* **Flexible API**: Choose between a `createPersistedSlice` utility or a `createPersistedReducer` builder syntax.
+
* **Nested State Support**: Easily persist slices or reducers that are deeply nested within your root state using a simple `nestedPath` option.
+
+* **Custom Serialization**: Use `onPersist` and `onRehydrate` to transform your state before saving and after loading.
+
* **Storage Agnostic**: Works with any storage provider that implements a simple `getItem`, `setItem`, and `removeItem` interface.
+
* **TypeScript Support**: Fully typed to ensure a great developer experience with path validation.
+
* **Minimal Footprint**: Extremely lightweight with a production size under 15 KB.
@@ -83,6 +93,7 @@ export const counterSlice = createPersistedSlice({
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
+
```
### Option 2: Using `createPersistedReducer`
@@ -114,6 +125,7 @@ export const counterReducer = createPersistedReducer(
});
}
);
+
```
### 2. Configure the Store
@@ -147,6 +159,7 @@ export const store = configurePersistedStore(
export type Store = Awaited;
export type RootState = ReturnType;
export type AppDispatch = Store['dispatch'];
+
```
@@ -163,7 +176,7 @@ This component replaces the standard `Provider` from `react-redux`. It waits for
In your application's entry point (e.g., `main.tsx` or `index.js`), wrap your `App` component with `PersistedProvider`.
-```tsx
+```typescript
// main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
@@ -178,62 +191,38 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
,
);
+
```
The `PersistedProvider` accepts two props:
+
* `store`: The promise returned by `configurePersistedStore`.
+
* `loader` (optional): A React node to display while the store is rehydrating.
### `usePersistedStore`
-A custom hook that provides access to the rehydrated store instance. This is useful for dispatching actions or accessing store methods like `flush`.
+A custom hook that provides access to the rehydrated store instance. This is useful for dispatching actions or accessing store methods.
#### Usage
-```tsx
+```typescript
import React from 'react';
import { usePersistedStore } from 'rtk-persist';
const MyComponent = () => {
const { store } = usePersistedStore();
- const handleSaveNow = () => {
- // Manually forces the store to save its current state to storage.
- store.flush();
+ const handleClear = () => {
+ // Manually clears the persisted state from storage.
+ store.clearPersistedState();
};
- return ;
+ return ;
};
-```
-
-
-
-## ↔️ Seamless Integration
-A core design principle of `rtk-persist` is that it should be easy to add or remove. The API is intentionally designed to mirror Redux Toolkit's, so enabling or disabling persistence is as simple as changing an import.
-
-**From this:**
-
-```typescript
-import { createSlice } from '@reduxjs/toolkit';
-
-export const counterSlice = createSlice({
- /* ... */
-});
```
-**To this:**
-
-```typescript
-import { createPersistedSlice } from 'rtk-persist';
-
-export const counterSlice = createPersistedSlice({
- /* ... */
-});
-```
-
-No other code changes are needed in your slice file.
-
## 🌳 Handling Nested State
@@ -256,7 +245,9 @@ export const counterSlice = createPersistedSlice(
/* ... */
},
},
- 'features.counter' // The nestedPath to the slice's state
+ {
+ nestedPath: 'features.counter' // The nestedPath to the slice's state
+ }
);
// app/store.ts
@@ -277,10 +268,74 @@ export const store = configurePersistedStore(
'my-app-id',
localStorage
);
+
```
+## 🔬 Advanced Usage: Custom Serialization
+
+Sometimes, you may need to transform a slice's state before it's saved to storage or after it's rehydrated. For example, you might want to store a `Date` object as an ISO string, or omit certain transient properties.
+
+`rtk-persist` supports this through the `onPersist` and `onRehydrate` options.
+
+### Example with `onPersist` and `onRehydrate`
+
+Here's how you can persist a slice that contains a non-serializable value like a `Date` object.
+
+```typescript
+// features/session/sessionSlice.ts
+import { createPersistedSlice } from 'rtk-persist';
+
+interface SessionState {
+ lastLogin: Date | null;
+ token: string | null;
+}
+
+const initialState: SessionState = {
+ lastLogin: null,
+ token: null,
+};
+
+export const sessionSlice = createPersistedSlice(
+ {
+ name: 'session',
+ initialState,
+ reducers: {
+ login: (state, action) => {
+ state.token = action.payload.token;
+ state.lastLogin = new Date();
+ },
+ logout: (state) => {
+ state.token = null;
+ state.lastLogin = null;
+ },
+ },
+ },
+ {
+ // Transform state before saving
+ onPersist: (state) => ({
+ ...state,
+ lastLogin: state.lastLogin ? state.lastLogin.toISOString() : null,
+ }),
+ // Transform state after rehydrating
+ onRehydrate: (state) => ({
+ ...state,
+ lastLogin: state.lastLogin ? new Date(state.lastLogin) : null,
+ }),
+ }
+);
+
+```
+
+In this example:
+
+* `onPersist` converts the `lastLogin` `Date` object into an ISO string before it's written to `localStorage`.
+
+* `onRehydrate` parses the ISO string and converts it back into a `Date` object when the state is loaded from storage.
+
+
+
## 🛠️ API
### `createPersistedSlice`
@@ -290,13 +345,18 @@ A wrapper around RTK's `createSlice` that adds persistence.
#### Takes
* **`sliceOptions`**: The standard `CreateSliceOptions` object from Redux Toolkit.
-* **`nestedPath`** (optional, `string`): A dot-notation string representing the path to the slice's state from the root. Required if the slice is not at the root level.
-#### Returns
+* **`persistenceOptions`** (optional, `object`): Configuration for persistence behavior.
+
+ * `nestedPath` (optional, `string`): A dot-notation string for the slice's state if it's nested.
+
+ * `onPersist` (optional, `function`): A function to transform state *before* it's saved.
+
+ * `onRehydrate` (optional, `function`): A function to transform state *after* it's rehydrated.
-* A standard `Slice` object, enhanced with a `nestedPath` property.
+#### Returns
----
+* A `PersistedSlice` object, which is a standard `Slice` object enhanced with persistence properties.
### `createPersistedReducer`
@@ -305,15 +365,22 @@ A wrapper around RTK's `createReducer` that adds persistence.
#### Takes
* **`name`**: A unique string to identify this reducer in storage.
+
* **`initialState`**: The initial state for the reducer.
+
* **`builderCallback`**: A callback that receives a `builder` object to define case reducers.
-* **`nestedPath`** (optional, `string`): A dot-notation string representing the path to the reducer's state. An empty string (`''`) signifies that this reducer is the root state.
-#### Returns
+* **`persistenceOptions`** (optional, `object`): Configuration for persistence behavior.
+
+ * `nestedPath` (optional, `string`): A dot-notation string for the reducer's state. An empty string (`''`) signifies the root state.
-* A standard `Reducer` function, enhanced with `reducerName` and `nestedPath` properties.
+ * `onPersist` (optional, `function`): A function to transform state *before* it's saved.
----
+ * `onRehydrate` (optional, `function`): A function to transform state *after* it's rehydrated.
+
+#### Returns
+
+* A `PersistedReducer` function, which is a standard `Reducer` enhanced with persistence properties.
### `configurePersistedStore`
@@ -322,16 +389,22 @@ A wrapper around RTK's `configureStore`.
#### Takes
* **`storeOptions`**: The standard `ConfigureStoreOptions` object.
+
* **`applicationId`**: A unique string that identifies the application to namespace storage keys.
+
* **`storageHandler`**: A storage object that implements `getItem`, `setItem`, and `removeItem`.
+
* **`persistenceOptions`** (optional): An object to control the persistence behavior:
- * `rehydrationTimeout` (optional, `number`): Max time in ms to wait for rehydration. Defaults to `5000`.
+
+ * `rehydrationTimeout` (optional, `number`): Max time in ms to wait for rehydration. Defaults to `5000`.
#### Returns
* A `Promise` object, which resolves to a standard Redux store enhanced with the following methods:
- * **`rehydrate()`**: A function to manually trigger rehydration from storage.
- * **`clearPersistedState()`**: A function that clears all persisted data for the application from storage.
+
+ * **`rehydrate()`**: A function to manually trigger rehydration from storage.
+
+ * **`clearPersistedState()`**: A function that clears all persisted data for the application from storage.
@@ -346,5 +419,5 @@ This library was crafted from our daily experiences building modern web and mobi
## 📄 License
This project is licensed under the MIT License.
-
+
Library icon freely created from a [iconsax](https://iconsax.io/) icon and the [redux](https://redux.js.org/img/redux.svg) logo.
diff --git a/src/core/reducer.ts b/src/core/reducer.ts
index 81e3125..bffa42c 100644
--- a/src/core/reducer.ts
+++ b/src/core/reducer.ts
@@ -10,6 +10,7 @@ import {
NestedPath,
NotFunction,
PersistedReducer,
+ ReducerPersistenceOptions,
RehydrateActionPayload,
} from './types';
import UpdatedAtHelper from './updatedAtHelper';
@@ -17,26 +18,51 @@ import { deepGetByPath, REHYDRATE, writePersistedStorage } from './utils';
/**
* Creates a persisted reducer that wraps Redux Toolkit's `createReducer`.
- * This function enhances a standard reducer with automatic state persistence,
- * saving its state to storage and rehydrating it on app startup.
*
- * This function must be used with a store configured by `configurePersistedStore`.
+ * This function enhances a standard reducer with automatic state persistence,
+ * saving its state to storage and rehydrating it when the application starts.
+ * It must be used with a store configured by `configurePersistedStore`.
*
- * @param reducerName - A unique string name for the reducer. This name serves as the key in both the root state and the storage.
- * @param initialState - The initial state for the reducer.
- * @param mapOrBuilderCallback - A callback that receives a `builder` object to define case reducers, similar to the original `createReducer`.
- * @param nestedPath - An optional dot-separated string path indicating where the reducer's state is located within the root state. If not provided, `reducerName` is used.
* @public
+ * @param reducerName - A unique name for the reducer. This name is used as the key
+ * in both the root state and the underlying storage.
+ * @param initialState - The initial state for the reducer, same as in `createReducer`.
+ * @param mapOrBuilderCallback - A callback that receives a `builder` object to define
+ * case reducers. This builder is wrapped to automatically track state changes for persistence.
+ * @param persistenceOptions - Optional configuration for customizing persistence behavior.
+ * @returns A reducer function enhanced with persistence capabilities.
+ *
+ * @example
+ * ```typescript
+ * const counterReducer = createPersistedReducer(
+ * 'counter',
+ * { value: 0 },
+ * (builder) => {
+ * builder.addCase(increment, (state) => {
+ * state.value++;
+ * });
+ * },
+ * {
+ * // Optional: Specify a different path in the root state.
+ * nestedPath: 'nested.counter',
+ * // Optional: Transform state before saving.
+ * onPersist: (state) => ({ value: state.value.toString() }),
+ * // Optional: Transform state after loading from storage.
+ * onRehydrate: (persistedState) => ({ value: parseInt(persistedState.value, 10) }),
+ * }
+ * );
+ * ```
*/
export const createPersistedReducer = <
ReducerName extends string,
S extends NotFunction,
+ SavedState,
Nesting extends NestedPath = ReducerName,
>(
reducerName: ReducerName,
initialState: S | (() => S),
mapOrBuilderCallback: (builder: ActionReducerMapBuilder) => void,
- nestedPath?: Nesting,
+ persistenceOptions?: ReducerPersistenceOptions,
): PersistedReducer => {
// Register the reducer for persistence tracking.
Settings.subscribeSlice(reducerName);
@@ -50,8 +76,9 @@ export const createPersistedReducer = <
/**
* The full dot-separated path to the reducer's state within the root state object.
* Defaults to the reducer's name if `nestedPath` is not provided.
+ * @internal
*/
- const finalNestedPath = (nestedPath ?? reducerName) as Nesting;
+ const finalNestedPath = (persistenceOptions?.nestedPath ?? reducerName) as Nesting;
/**
* Debounces the `writePersistedStorage` function to prevent excessive writes
@@ -63,7 +90,17 @@ export const createPersistedReducer = <
if (debounceTimeout) clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
const reducerState = deepGetByPath(state, finalNestedPath);
- writePersistedStorage(reducerState, reducerName);
+ if (reducerState === null) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.error(`DUMP: No state found for ${reducerName}, check if the nestedPath is corrected.`);
+ }
+ } else {
+ if (persistenceOptions && 'onPersist' in persistenceOptions) {
+ writePersistedStorage(persistenceOptions.onPersist(reducerState), reducerName);
+ } else {
+ writePersistedStorage(reducerState, reducerName);
+ }
+ }
}, 100);
};
@@ -82,10 +119,16 @@ export const createPersistedReducer = <
// Add a case to handle the rehydration of state from storage.
builder.addCase(
REHYDRATE.toString(),
- (_state, action: PayloadAction>):
+ (_state, action: PayloadAction>):
| void
| S => {
- if (action.payload?.[reducerName]) return action.payload[reducerName];
+ if (action.payload?.[reducerName]) {
+ if (persistenceOptions && 'onRehydrate' in persistenceOptions) {
+ return persistenceOptions.onRehydrate(action.payload[reducerName] as SavedState);
+ } else {
+ return action.payload[reducerName] as S;
+ }
+ }
},
);
// Wrap the builder to automatically track state changes for persistence.
diff --git a/src/core/slice.ts b/src/core/slice.ts
index 46f3136..853bf0f 100644
--- a/src/core/slice.ts
+++ b/src/core/slice.ts
@@ -8,7 +8,12 @@ import {
import { Builder } from './extraReducersBuilder';
import { listenerMiddleware } from './middleware';
import Settings from './settings';
-import { NestedPath, PersistedSlice, RehydrateActionPayload } from './types';
+import {
+ NestedPath,
+ PersistedSlice,
+ RehydrateActionPayload,
+ SlicePersistenceOptions,
+} from './types';
import UpdatedAtHelper from './updatedAtHelper';
import { deepGetByPath, REHYDRATE, writePersistedStorage } from './utils';
@@ -19,17 +24,48 @@ import { deepGetByPath, REHYDRATE, writePersistedStorage } from './utils';
*
* This function must be used with a store configured by `configurePersistedStore`.
*
+ * @public
* @param sliceOptions - The standard `CreateSliceOptions` object from Redux Toolkit.
- * @param nestedPath - An optional dot-separated string path indicating where the
- * slice's state is located within the root state. If not provided, `reducerPath`
- * or `name` from `sliceOptions` is used.
+ * @param persistenceOptions - Optional configuration for persistence behavior.
+ * @param persistenceOptions.nestedPath - A dot-separated string path indicating where the
+ * slice's state is located within the root state. If not provided, it defaults to
+ * `reducerPath` or `name` from `sliceOptions`.
+ * @param persistenceOptions.onPersist - A function that transforms the slice's state
+ * *before* it is saved to storage. This is useful for saving a different version of the state
+ * than what is used in the application.
+ * @param persistenceOptions.onRehydrate - A function that transforms the state *after* it is
+ * loaded from storage but *before* it is placed in the Redux store. This is useful for
+ * migrating old state shapes or re-instantiating complex objects.
* @returns A Redux slice object with persistence enabled, augmented with a
- * `nestedPath` property for state tracking.
+ * `nestedPath` property for internal state tracking.
*
- * @public
+ * @example
+ * // Basic usage
+ * const counterSlice = createPersistedSlice({
+ * name: 'counter',
+ * initialState: { value: 0 },
+ * reducers: {
+ * increment: (state) => {
+ * state.value += 1;
+ * },
+ * },
+ * });
+ *
+ * // Usage with persistence options
+ * const userSlice = createPersistedSlice({
+ * name: 'user',
+ * initialState: { data: null, loadedAt: null },
+ * reducers: {
+ * // ...
+ * },
+ * }, {
+ * onRehydrate: (savedState) => ({ ...savedState, loadedAt: new Date() }),
+ * onPersist: (state) => ({ data: state.data }), // Only persist the 'data' field
+ * });
*/
export const createPersistedSlice = <
SliceState,
+ SavedState,
Name extends string,
PCR extends SliceCaseReducers,
ReducerPath extends string = Name,
@@ -43,7 +79,13 @@ export const createPersistedSlice = <
ReducerPath,
PersistedSelectors
>,
- nestedPath?: Nesting,
+ persistenceOptions?: SlicePersistenceOptions<
+ SliceState,
+ SavedState,
+ Name,
+ ReducerPath,
+ Nesting
+ >,
): PersistedSlice<
SliceState,
PCR,
@@ -63,8 +105,9 @@ export const createPersistedSlice = <
/**
* The full dot-separated path to the slice's state within the root state object.
+ * @internal
*/
- const finalNestedPath = (nestedPath ??
+ const finalNestedPath = (persistenceOptions?.nestedPath ??
sliceOptions.reducerPath ??
sliceOptions.name) as Nesting;
@@ -78,7 +121,20 @@ export const createPersistedSlice = <
if (debounceTimeout) clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
const sliceState = deepGetByPath(state, finalNestedPath);
- writePersistedStorage(sliceState, sliceOptions.name);
+ if (sliceState === null) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.error(`DUMP: No state found for ${sliceOptions.name}, check if the nestedPath is corrected.`);
+ }
+ } else {
+ if (persistenceOptions && 'onPersist' in persistenceOptions) {
+ writePersistedStorage(
+ persistenceOptions.onPersist(sliceState),
+ sliceOptions.name,
+ );
+ } else {
+ writePersistedStorage(sliceState, sliceOptions.name);
+ }
+ }
}, 100);
};
@@ -101,10 +157,19 @@ export const createPersistedSlice = <
REHYDRATE.toString(),
(
_state,
- action: PayloadAction>,
+ action: PayloadAction<
+ RehydrateActionPayload
+ >,
): void | SliceState => {
- if (action.payload?.[sliceOptions.name])
- return action.payload[sliceOptions.name];
+ if (action.payload?.[sliceOptions.name]) {
+ if (persistenceOptions && 'onRehydrate' in persistenceOptions) {
+ return persistenceOptions.onRehydrate(
+ action.payload[sliceOptions.name] as SavedState,
+ );
+ } else {
+ return action.payload[sliceOptions.name] as SliceState;
+ }
+ }
},
);
// Wrap the builder to automatically track state changes for persistence.
diff --git a/src/core/store.ts b/src/core/store.ts
index 46e4867..23c0a88 100644
--- a/src/core/store.ts
+++ b/src/core/store.ts
@@ -15,8 +15,8 @@ import {
ExtractDispatchExtensions,
Middlewares,
PersistedStore,
- PersistenceOptions,
StorageHandler,
+ StorePersistenceOptions,
ThunkMiddlewareFor,
} from './types';
import { clearPersistedStorage, getStoredState, REHYDRATE } from './utils';
@@ -64,7 +64,7 @@ export const configurePersistedStore: <
options: ConfigureStoreOptions>, E, P>,
applicationId: string,
storageHandler: StorageHandler,
- persistenceOptions?: PersistenceOptions,
+ persistenceOptions?: StorePersistenceOptions,
) => Promise> = async <
S extends Record = any,
A extends Action = UnknownAction,
@@ -82,7 +82,7 @@ export const configurePersistedStore: <
options: ConfigureStoreOptions>, E, P>,
applicationId: string,
storageHandler: StorageHandler,
- persistenceOptions: PersistenceOptions = {
+ persistenceOptions: StorePersistenceOptions = {
rehydrationTimeout: 5000,
},
) => {
diff --git a/src/core/types.ts b/src/core/types.ts
index 92ebf07..26d97d7 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -16,9 +16,9 @@ import {
} from '@reduxjs/toolkit';
/**
- * Defines the interface for a storage handler, allowing `rtk-persist` to
- * work with different storage mechanisms like web `localStorage` or React
- * Native's `AsyncStorage`.
+ * Defines the interface for a storage handler, enabling `rtk-persist` to
+ * integrate with various storage mechanisms like web `localStorage` or
+ * React Native's `AsyncStorage`.
*
* @example
* // For web environments:
@@ -30,17 +30,25 @@ import {
* @public
*/
export interface StorageHandler {
- /** Saves a key-value pair to the storage. */
+ /** * Saves a key-value pair to the storage.
+ * @param key - The key under which to store the value.
+ * @param value - The string value to store.
+ */
setItem: (key: string, value: string) => Promise | void;
- /** Retrieves a value from storage by its key. */
+ /** * Retrieves a value from storage by its key.
+ * @param key - The key of the item to retrieve.
+ * @returns A promise resolving to the value, or the value directly, or null if not found.
+ */
getItem: (key: string) => Promise | string | null;
- /** Removes a key-value pair from storage. */
+ /** * Removes a key-value pair from storage.
+ * @param key - The key of the item to remove.
+ */
removeItem: (key: string) => Promise | void;
}
/**
* Defines the shape of the payload for the internal `REHYDRATE` action.
- * It's a record where keys are slice names and values are their persisted states.
+ * This is a record where keys are slice names and values are their persisted states.
* @internal
*/
export type RehydrateActionPayload<
@@ -48,11 +56,6 @@ export type RehydrateActionPayload<
State = unknown,
> = Record | null;
-// --- Internal RTK Types ---
-// These types are re-exported or re-defined from Redux Toolkit to ensure
-// proper type inference in `configurePersistedStore`. They are not intended
-// for direct use.
-
/** @internal */
export type Enhancers = ReadonlyArray;
/** @internal */
@@ -107,16 +110,16 @@ export type ExtractDispatchExtensions = M extends Tuple = T extends Function ? never : T;
/**
- * A utility type that defines a valid nesting path for a persisted slice or reducer.
- * The path must be a dot-notation string that ends with the slice/reducer's name.
- * An empty string is also valid for root-level items.
+ * Defines a valid nesting path for a persisted slice or reducer.
+ * The path is a dot-notation string that must end with the slice/reducer's name.
+ * An empty string is also valid for items at the root level of the state.
*
* @template Path - The name or path of the reducer, which must be the final segment.
*/
@@ -131,15 +134,15 @@ export type PersistedReducer<
ReducerName extends string,
Nesting extends NestedPath = ReducerName,
> = Reducer & {
- /** The unique name of the reducer, used as the storage key. */
+ /** The unique name of the reducer, used as the key in storage. */
reducerName: ReducerName;
- /** The full dot-separated path to the reducer's state in the root state object. */
+ /** The full dot-separated path to the reducer's state within the root state object. */
nestedPath: Nesting;
};
/**
* An enhanced Redux slice that includes the `nestedPath` property for
- * tracking its location within the root state.
+ * tracking its location within the root state, allowing for persistence of nested state.
* @public
*/
export type PersistedSlice<
@@ -150,7 +153,7 @@ export type PersistedSlice<
PersistedSelectors extends SliceSelectors,
Nesting extends NestedPath = ReducerPath,
> = Slice & {
- /** The full dot-separated path to the slice's state in the root state object. */
+ /** The full dot-separated path to the slice's state within the root state object. */
nestedPath: Nesting;
};
@@ -175,21 +178,69 @@ export type PersistedStore<
> = EnhancedStore & {
/**
* Manually triggers the rehydration of the store's state from storage.
+ * This is useful for scenarios like re-authenticating a user.
* @returns A promise that resolves when rehydration is complete.
*/
rehydrate: () => Promise;
/**
- * Clears all persisted state for the application from storage.
+ * Clears all persisted state for this store from the storage engine.
* @returns A promise that resolves when the state has been cleared.
*/
clearPersistedState: () => Promise;
};
/**
- * Defines configuration options for the persistence and rehydration process.
+ * Defines global configuration options for the store's persistence and rehydration process.
* @public
*/
-export interface PersistenceOptions {
+export interface StorePersistenceOptions {
/** The maximum time in milliseconds to wait for rehydration before timing out. Defaults to 5000. */
rehydrationTimeout?: number;
}
+
+/**
+ * Defines persistence options for an individual slice, allowing for custom
+ * serialization and deserialization logic.
+ * @public
+ */
+export type SlicePersistenceOptions<
+ SliceState,
+ SavedState,
+ Name extends string,
+ ReducerPath extends string = Name,
+ Nesting extends NestedPath = ReducerPath,
+> = {
+ /**
+ * Specifies the dot-notation path to the slice's state if it's nested.
+ * If not provided, it's assumed to be at the root.
+ */
+ nestedPath?: Nesting;
+} & ({
+ /** A function to transform the slice's state before it's saved to storage. */
+ onPersist: (sliceState: SliceState) => SavedState;
+ /** A function to transform the saved state back into the slice's state upon rehydration. */
+ onRehydrate: (savedState: SavedState) => SliceState;
+} | {});
+
+/**
+ * Defines persistence options for an individual reducer, allowing for custom
+ * serialization and deserialization logic.
+ * @public
+ */
+export type ReducerPersistenceOptions<
+ ReducerName extends string,
+ S extends NotFunction,
+ SavedState,
+ Nesting extends NestedPath = ReducerName,
+> = {
+ /**
+ * Specifies the dot-notation path to the reducer's state if it's nested.
+ * If not provided, it's assumed to be at the root.
+ */
+ nestedPath?: Nesting;
+} & ({
+ /** A function to transform the reducer's state before it's saved to storage. */
+ onPersist: (sliceState: S) => SavedState;
+ /** A function to transform the saved state back into the reducer's state upon rehydration. */
+ onRehydrate: (savedState: SavedState) => S;
+} | {});
diff --git a/src/core/utils.ts b/src/core/utils.ts
index 64b3435..b7c87f1 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -101,7 +101,7 @@ export const deepGet = (
for (const key of keys) {
// If at any point the path leads to a non-object (and is not the end of the path),
// we cannot go deeper, so the path is considered invalid.
- if (typeof current !== 'object' || current === null) {
+ if (typeof current !== 'object' || current === null || !(key in current)) {
return null;
}
current = current[key];
diff --git a/test/core/reducer.test.ts b/test/core/reducer.test.ts
index ffba3f5..164860e 100644
--- a/test/core/reducer.test.ts
+++ b/test/core/reducer.test.ts
@@ -6,15 +6,19 @@
*/
import { createAction, PayloadAction } from '@reduxjs/toolkit';
+import { listenerMiddleware } from '../../src/core/middleware';
import { createPersistedReducer } from '../../src/core/reducer';
-import { RehydrateActionPayload } from '../../src/core/types';
+import { configurePersistedStore } from '../../src/core/store';
+import { RehydrateActionPayload, StorageHandler } from '../../src/core/types';
import { REHYDRATE } from '../../src/core/utils';
+import { flushTimersAndPromises, StorageMock } from './mocks';
describe('createPersistedReducer', () => {
// --- Test Setup ---
const reducerName = 'test';
const initialState = { value: 0 };
const increment = createAction('increment');
+ let storage: StorageHandler;
// A mock callback that defines the reducer's logic.
const mapOrBuilderCallback = jest.fn((builder: any) => {
@@ -27,7 +31,16 @@ describe('createPersistedReducer', () => {
});
beforeEach(() => {
+ // Set up a fresh mock storage and use fake timers for debounce control.
+ storage = new StorageMock();
mapOrBuilderCallback.mockClear();
+ listenerMiddleware.clearListeners();
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ // Clean up timers after each test.
+ jest.useRealTimers();
});
// --- Core Reducer Functionality ---
@@ -77,7 +90,9 @@ describe('createPersistedReducer', () => {
reducerName,
initialState,
mapOrBuilderCallback,
- nestedPath,
+ {
+ nestedPath,
+ },
);
expect(reducer.nestedPath).toBe(nestedPath);
});
@@ -149,6 +164,133 @@ describe('createPersistedReducer', () => {
});
});
+ // --- Persistence Options ---
+
+ describe('Persistence Options (onPersist/onRehydrate)', () => {
+ it('should use onPersist to transform state before persisting', async () => {
+ // Arrange: onPersist will omit the 'sensitive' field.
+ const update = createAction('update');
+ const reducer = createPersistedReducer(
+ 'sensitive',
+ { sensitive: 'secret', safe: 'public' },
+ (builder) => {
+ builder.addCase(update, (state) => {
+ state.sensitive = 'new-secret';
+ state.safe = 'new-public';
+ });
+ },
+ {
+ onPersist: (state) => ({ safe: state.safe }), // Only persist the 'safe' field
+ onRehydrate: (savedState) => ({ safe: savedState.safe, sensitive: 'secret' })
+ },
+ );
+ const store = await configurePersistedStore(
+ { reducer: { sensitive: reducer } },
+ 'testApp1',
+ storage,
+ );
+ await flushTimersAndPromises();
+
+ // Act
+ store.dispatch(update());
+ await flushTimersAndPromises();
+
+ // Assert: The stored data should be transformed.
+ const persistedState = await storage.getItem(`persist:testApp1-sensitive`);
+ const parsed = JSON.parse(persistedState!);
+ expect(parsed.safe).toBe('new-public');
+ expect(parsed.sensitive).toBeUndefined();
+ });
+
+ it('should use onRehydrate to transform state on startup', async () => {
+ // Arrange: Pre-seed storage with a legacy data format.
+ await storage.setItem(`persist:testApp2-legacy`, '{"v1_data": 42}');
+ const reducer = createPersistedReducer(
+ 'legacy',
+ { version: 2, data: 0 },
+ () => {},
+ {
+ onPersist: (state) => ({
+ v1_data: state.data,
+ }),
+ onRehydrate: (savedState) => ({
+ version: 2, // Add new field
+ data: savedState.v1_data, // Map old field to new field
+ }),
+ },
+ );
+
+ // Act: Configure the store to trigger rehydration.
+ const store1 = await configurePersistedStore(
+ { reducer: { legacy: reducer } },
+ 'testApp2',
+ storage,
+ );
+ await flushTimersAndPromises();
+
+ // Assert: The state should be correctly transformed.
+ expect(store1.getState().legacy).toEqual({ version: 2, data: 42 });
+ });
+
+ it('should correctly perform a round-trip with onPersist and onRehydrate', async () => {
+ // Arrange: Use onPersist to minify state and onRehydrate to expand it.
+ type SliceState = { user: string; lastLogin: number };
+ type SavedState = { u: string; l: number };
+
+ const login = createAction('login');
+
+ const reducer = createPersistedReducer(
+ 'minified',
+ { user: '', lastLogin: 0 } as SliceState,
+ (builder) => {
+ builder.addCase(login, (state, action: PayloadAction) => {
+ state.user = action.payload.user;
+ state.lastLogin = action.payload.lastLogin;
+ });
+ },
+ {
+ onPersist: (state: SliceState): SavedState => ({
+ u: state.user,
+ l: state.lastLogin,
+ }),
+ onRehydrate: (saved: SavedState): SliceState => ({
+ user: saved.u,
+ lastLogin: saved.l,
+ }),
+ },
+ );
+
+ // --- Part 1: Persist the data ---
+ const store1 = await configurePersistedStore(
+ { reducer: { minified: reducer } },
+ 'testApp3',
+ storage,
+ );
+ await flushTimersAndPromises();
+
+ store1.dispatch(login({ user: 'test', lastLogin: 123 }));
+ await flushTimersAndPromises();
+
+ // Assert that the minified version was stored.
+ const persisted = await storage.getItem(`persist:testApp3-minified`);
+ expect(JSON.parse(persisted!)).toEqual({ u: 'test', l: 123 });
+
+ // --- Part 2: Rehydrate the data ---
+ const store2 = await configurePersistedStore(
+ { reducer: { minified: reducer } },
+ 'testApp3',
+ storage,
+ );
+ await flushTimersAndPromises();
+
+ // Assert that the state was correctly expanded upon rehydration.
+ expect(store2.getState().minified).toEqual({
+ user: 'test',
+ lastLogin: 123,
+ });
+ });
+ });
+
// --- Advanced Builder Cases ---
describe('Advanced Builder Cases', () => {
diff --git a/test/core/slice.test.ts b/test/core/slice.test.ts
index bc95587..2f13552 100644
--- a/test/core/slice.test.ts
+++ b/test/core/slice.test.ts
@@ -5,6 +5,7 @@
*/
import { combineReducers, createAction, PayloadAction } from '@reduxjs/toolkit';
+import { listenerMiddleware } from '../../src/core/middleware';
import { TestSettings } from '../../src/core/settings';
import { createPersistedSlice } from '../../src/core/slice';
import { configurePersistedStore } from '../../src/core/store';
@@ -17,6 +18,7 @@ describe('createPersistedSlice', () => {
beforeEach(() => {
// Set up a fresh mock storage and use fake timers for debounce control.
storage = new StorageMock();
+ listenerMiddleware.clearListeners();
TestSettings.restoreDefaults();
jest.useFakeTimers();
});
@@ -201,7 +203,9 @@ describe('createPersistedSlice', () => {
},
},
},
- 'nested.sliceB',
+ {
+ nestedPath: 'nested.sliceB',
+ },
);
const nestedReducer = combineReducers({ [sliceB.name]: sliceB.reducer });
const store = await configurePersistedStore(
@@ -323,4 +327,132 @@ describe('createPersistedSlice', () => {
});
});
});
+
+ describe('Persistence Options (onPersist/onRehydrate)', () => {
+ it('should use onPersist to transform state before persisting', async () => {
+ // Arrange: onPersist will omit the 'sensitive' field.
+ const slice = createPersistedSlice(
+ {
+ name: 'secure',
+ initialState: { sensitive: 'secret', safe: 'public' },
+ reducers: {
+ update: (state) => {
+ state.sensitive = 'new-secret';
+ state.safe = 'new-public';
+ },
+ },
+ },
+ {
+ onPersist: (state) => ({ safe: state.safe }), // Only persist the 'safe' field
+ onRehydrate: (savedState) => ({ safe: savedState.safe, sensitive: 'secret' })
+ },
+ );
+ const store = await configurePersistedStore(
+ { reducer: { [slice.name]: slice.reducer } },
+ 'testApp',
+ storage,
+ );
+ await flushTimersAndPromises();
+
+ // Act
+ store.dispatch(slice.actions.update());
+ await flushTimersAndPromises();
+
+ // Assert: The stored data should be transformed.
+ const persistedState = await storage.getItem('persist:testApp-secure');
+ const parsed = JSON.parse(persistedState!);
+ expect(parsed.safe).toBe('new-public');
+ expect(parsed.sensitive).toBeUndefined();
+ });
+
+ it('should use onRehydrate to transform state on startup', async () => {
+ // Arrange: Pre-seed storage with a legacy data format.
+ await storage.setItem('persist:testApp-legacy', '{"v1_data": 42}');
+ const slice = createPersistedSlice(
+ {
+ name: 'legacy',
+ initialState: { version: 2, data: 0 },
+ reducers: {},
+ },
+ {
+ onPersist: (state) => ({
+ v1_data: state.data,
+ }),
+ onRehydrate: (savedState) => ({
+ version: 2, // Add new field
+ data: savedState.v1_data, // Map old field to new field
+ }),
+ },
+ );
+
+ // Act: Configure the store to trigger rehydration.
+ const store = await configurePersistedStore(
+ { reducer: { [slice.name]: slice.reducer } },
+ 'testApp',
+ storage,
+ );
+ await flushTimersAndPromises();
+
+ // Assert: The state should be correctly transformed.
+ expect(store.getState().legacy).toEqual({ version: 2, data: 42 });
+ });
+
+ it('should correctly perform a round-trip with onPersist and onRehydrate', async () => {
+ // Arrange: Use onPersist to minify state and onRehydrate to expand it.
+ type SliceState = { user: string; lastLogin: number };
+ type SavedState = { u: string; l: number };
+
+ const slice = createPersistedSlice(
+ {
+ name: 'minified',
+ initialState: { user: '', lastLogin: 0 } as SliceState,
+ reducers: {
+ login: (state, action: PayloadAction) => {
+ state.user = action.payload.user;
+ state.lastLogin = action.payload.lastLogin;
+ },
+ },
+ },
+ {
+ onPersist: (state: SliceState): SavedState => ({
+ u: state.user,
+ l: state.lastLogin,
+ }),
+ onRehydrate: (saved: SavedState): SliceState => ({
+ user: saved.u,
+ lastLogin: saved.l,
+ }),
+ },
+ );
+
+ // --- Part 1: Persist the data ---
+ const store1 = await configurePersistedStore(
+ { reducer: { [slice.name]: slice.reducer } },
+ 'testApp',
+ storage,
+ );
+ store1.dispatch(slice.actions.login({ user: 'test', lastLogin: 123 }));
+ await flushTimersAndPromises();
+
+ // Assert that the minified version was stored.
+ const persisted = await storage.getItem('persist:testApp-minified');
+ expect(JSON.parse(persisted!)).toEqual({ u: 'test', l: 123 });
+
+ // --- Part 2: Rehydrate the data ---
+ TestSettings.restoreDefaults(); // Reset settings for a clean startup
+ TestSettings.subscribeSlice(slice.name);
+ const store2 = await configurePersistedStore(
+ { reducer: { [slice.name]: slice.reducer } },
+ 'testApp',
+ storage,
+ );
+ await flushTimersAndPromises();
+
+ // Assert that the state was correctly expanded upon rehydration.
+ expect(store2.getState().minified).toEqual({
+ user: 'test',
+ lastLogin: 123,
+ });
+ });
+ });
});
diff --git a/test/core/store.test.ts b/test/core/store.test.ts
index 408008a..6ee1125 100644
--- a/test/core/store.test.ts
+++ b/test/core/store.test.ts
@@ -5,6 +5,7 @@
*/
import { createAction } from '@reduxjs/toolkit';
+import { listenerMiddleware } from '../../src/core/middleware';
import { createPersistedReducer } from '../../src/core/reducer';
import { TestSettings } from '../../src/core/settings';
import { createPersistedSlice } from '../../src/core/slice';
@@ -19,6 +20,7 @@ describe('configurePersistedStore', () => {
beforeEach(() => {
// Set up a fresh mock storage for each test and use fake timers.
storage = new StorageMock();
+ listenerMiddleware.clearListeners();
TestSettings.restoreDefaults();
jest.useFakeTimers();
});
@@ -116,7 +118,9 @@ describe('configurePersistedStore', () => {
state.value += 1;
});
},
- '', // Empty string signifies a root reducer.
+ {
+ nestedPath: '', // Empty string signifies a root reducer.
+ },
);
// Act
@@ -212,7 +216,7 @@ describe('configurePersistedStore', () => {
const sliceA = createPersistedSlice(
{ name: 'sliceA', initialState: { value: 'a' }, reducers: {} },
- 'level1.level2.sliceA',
+ { nestedPath: 'level1.level2.sliceA' },
);
// This should compile without errors.