Skip to content

Commit fd174db

Browse files
Add createPersistedReducer
1 parent 8dabb4f commit fd174db

File tree

5 files changed

+424
-59
lines changed

5 files changed

+424
-59
lines changed

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { createPersistedReducer } from './reducer';
12
import { createPersistedSlice } from "./slice";
23
import { configurePersistedStore } from "./store";
34

45
export {
5-
createPersistedSlice,
66
configurePersistedStore,
7+
createPersistedReducer,
8+
createPersistedSlice
79
};

src/reducer.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import {
2+
ActionReducerMapBuilder,
3+
createReducer,
4+
PayloadAction
5+
} from '@reduxjs/toolkit';
6+
import { Builder } from './extraReducersBuilder';
7+
import { listenerMiddleware } from './middleware';
8+
import Settings from './settings';
9+
import { DEFAULT_INIT_ACTION_TYPE, NotFunction, PersistedReducer } from './types';
10+
import UpdatedAtHelper from './updatedAtHelper';
11+
import { getStorageName } from './utils';
12+
13+
/**
14+
* A utility function that allows defining a reducer as a mapping from action
15+
* type to *case reducer* functions that handle these action types. The
16+
* reducer's initial state is passed as the first argument.
17+
*
18+
* The state will be persisted throughout multiple reloads.
19+
* It requires to use {@link configurePersistedStore | configurePersistedStore}
20+
*
21+
* @remarks
22+
* The body of every case reducer is implicitly wrapped with a call to
23+
* `produce()` from the [immer](https://github.com/mweststrate/immer) library.
24+
* This means that rather than returning a new state object, you can also
25+
* mutate the passed-in state object directly; these mutations will then be
26+
* automatically and efficiently translated into copies, giving you both
27+
* convenience and immutability.
28+
*
29+
* @overloadSummary
30+
* This function accepts a callback that receives a `builder` object as its argument.
31+
* That builder provides `addCase`, `addMatcher` and `addDefaultCase` functions that may be
32+
* called to define what actions this reducer will handle.
33+
*
34+
* @param reducerName - string: a uniq name for the state slice implemented.
35+
* @param initialState - `State | (() => State)`: The initial state that should be used when the reducer is called the first time. This may also be a "lazy initializer" function, which should return an initial state value when called. This will be used whenever the reducer is called with `undefined` as its state value, and is primarily useful for cases like reading initial state from `localStorage`.
36+
* @param builderCallback - `(builder: Builder) => void` A callback that receives a *builder* object to define
37+
* case reducers via calls to `builder.addCase(actionCreatorOrType, reducer)`.
38+
* @example
39+
```ts
40+
import {
41+
createAction,
42+
createReducer,
43+
UnknownAction,
44+
PayloadAction,
45+
} from "@reduxjs/toolkit";
46+
47+
const increment = createAction<number>("increment");
48+
const decrement = createAction<number>("decrement");
49+
50+
function isActionWithNumberPayload(
51+
action: UnknownAction
52+
): action is PayloadAction<number> {
53+
return typeof action.payload === "number";
54+
}
55+
56+
const { reducer } = createPersistedReducer(
57+
'counters',
58+
{
59+
counter: 0,
60+
sumOfNumberPayloads: 0,
61+
unhandledActions: 0,
62+
},
63+
(builder) => {
64+
builder
65+
.addCase(increment, (state, action) => {
66+
// action is inferred correctly here
67+
state.counter += action.payload;
68+
})
69+
// You can chain calls, or have separate `builder.addCase()` lines each time
70+
.addCase(decrement, (state, action) => {
71+
state.counter -= action.payload;
72+
})
73+
// You can apply a "matcher function" to incoming actions
74+
.addMatcher(isActionWithNumberPayload, (state, action) => {})
75+
// and provide a default case if no other handlers matched
76+
.addDefaultCase((state, action) => {});
77+
}
78+
);
79+
```
80+
* @public
81+
*/
82+
export const createPersistedReducer: <ReducerName extends string, S extends NotFunction<any>>(reducerName: ReducerName, initialState: S | (() => S), mapOrBuilderCallback: (builder: ActionReducerMapBuilder<S>) => void, filtersSlice?: (state: S) => Partial<S>) => PersistedReducer<ReducerName, S> = <ReducerName extends string, S extends NotFunction<any>>(
83+
reducerName: ReducerName,
84+
initialState: S | (() => S),
85+
mapOrBuilderCallback: (builder: ActionReducerMapBuilder<S>) => void,
86+
filtersSlice: (state: S) => Partial<S> = state => state,
87+
) => {
88+
const storageName = getStorageName(reducerName);
89+
90+
/**
91+
* Creates a typed version of the startListening function
92+
* of the listener middlerware
93+
*
94+
* {@link @reduxjs/toolkit#createListenerMiddleware}
95+
*/
96+
const startAppListening =
97+
listenerMiddleware.startListening.withTypes<
98+
Record<ReducerName, S>
99+
>();
100+
101+
/**
102+
* Writes the updated state to the selected storage
103+
*
104+
* @param storedData The state to be persisted
105+
*
106+
* @internal
107+
*/
108+
async function writePersistedStorage(storedData: S) {
109+
await Settings.storageHandler.setItem(
110+
storageName,
111+
JSON.stringify(filtersSlice(storedData)),
112+
);
113+
UpdatedAtHelper.onSave(reducerName);
114+
}
115+
116+
/**
117+
* Clears the stored data from the selected storage
118+
*
119+
* @public
120+
*/
121+
async function clearPersistedStorage() {
122+
await Settings.storageHandler.removeItem(storageName);
123+
}
124+
125+
/**
126+
* Creates the reducer using the default options passed by the user.
127+
*
128+
* Extends the default extra reducer builder to update a stored var
129+
* the tracks the last time the state was updated.
130+
*
131+
*/
132+
const reducer = createReducer(initialState, builder => {
133+
builder.addMatcher(({ type }) => type === `${reducerName}\\${DEFAULT_INIT_ACTION_TYPE}`, (_state, action: PayloadAction<S | null>): void | S => {
134+
if (action.payload) return action.payload;
135+
});
136+
const b = new Builder(builder, UpdatedAtHelper.onStateChange.bind(null, reducerName));
137+
mapOrBuilderCallback(b);
138+
});
139+
140+
/**
141+
* Adds the listener to any actions and updates the stored
142+
* data if a change happened.
143+
*
144+
* We track all the changes of our state updating a custom
145+
* attribute saving the time when the change happened.
146+
*/
147+
startAppListening({
148+
predicate: (action) => {
149+
if (action.type === DEFAULT_INIT_ACTION_TYPE) return false;
150+
return true;
151+
},
152+
effect: async (_action, { getState }) => {
153+
if (!await UpdatedAtHelper.shouldSave(reducerName)) return;
154+
const state = getState();
155+
writePersistedStorage(state[reducerName]);
156+
},
157+
});
158+
159+
startAppListening({
160+
type: `${reducerName}/${DEFAULT_INIT_ACTION_TYPE}`,
161+
effect: () => {
162+
UpdatedAtHelper.onStateChange(reducerName);
163+
},
164+
});
165+
166+
return { reducer, reducerName, listenerMiddleware, clearPersistedStorage };
167+
};

src/slice.ts

Lines changed: 39 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,7 @@ import { listenerMiddleware } from './middleware';
1212
import Settings from './settings';
1313
import { DEFAULT_INIT_ACTION_TYPE } from './types';
1414
import UpdatedAtHelper from './updatedAtHelper';
15-
16-
const getStorageName = (sliceName: string) => `persisted-storage-${sliceName}`;
17-
18-
/**
19-
* Return the stored data of a slice if saved
20-
*
21-
* @returns The stored state of the slice if saved
22-
*
23-
* @public
24-
*/
25-
export async function getStoredState<T>(sliceName: string): Promise<Partial<T> | null> {
26-
try {
27-
const storageJson = (await Settings.storageHandler.getItem(getStorageName(sliceName)));
28-
if (!storageJson) return null;
29-
return JSON.parse(storageJson);
30-
} catch (e) {
31-
// console.error(e);
32-
}
33-
return null;
34-
}
15+
import { getStorageName, getStoredState } from './utils';
3516

3617
/**
3718
* A function that accepts an initial state, an object full of reducer
@@ -102,46 +83,47 @@ export const createPersistedSlice: <
10283
Record<Name, SliceState>
10384
>();
10485

105-
/**
106-
* Overrides the getInitialState function to return the stored data
107-
*
108-
* @returns The initial state of the slice merged with the stored data
109-
*
110-
* @public
111-
*/
112-
async function getInitialState(): Promise<SliceState> {
113-
let storage: Partial<SliceState> = slice.getInitialState();
114-
try {
115-
storage = await getStoredState(sliceOptions.name) ?? storage;
116-
} catch (e) {
117-
// console.error(e);
118-
}
119-
return { ...slice.getInitialState(), ...storage };
86+
/**
87+
* Overrides the getInitialState function to return the stored data
88+
*
89+
* @returns The initial state of the slice merged with the stored data
90+
*
91+
* @public
92+
*/
93+
// TODO: verify if we should inject the persisted state when reinizializing (I think we should remove this override)
94+
async function getInitialState(): Promise<SliceState> {
95+
let storage: Partial<SliceState> = slice.getInitialState();
96+
try {
97+
storage = await getStoredState(sliceOptions.name) ?? storage;
98+
} catch (e) {
99+
// console.error(e);
120100
}
101+
return { ...slice.getInitialState(), ...storage };
102+
}
121103

122-
/**
123-
* Writes the updated state to the selected storage
124-
*
125-
* @param storedData The state to be persisted
126-
*
127-
* @internal
128-
*/
129-
async function writePersistedStorage(storedData: SliceState) {
130-
await Settings.storageHandler.setItem(
131-
storageName,
132-
JSON.stringify(filtersSlice(storedData)),
133-
);
134-
UpdatedAtHelper.onSave(sliceOptions.name);
135-
}
104+
/**
105+
* Writes the updated state to the selected storage
106+
*
107+
* @param storedData The state to be persisted
108+
*
109+
* @internal
110+
*/
111+
async function writePersistedStorage(storedData: SliceState) {
112+
await Settings.storageHandler.setItem(
113+
storageName,
114+
JSON.stringify(filtersSlice(storedData)),
115+
);
116+
UpdatedAtHelper.onSave(sliceOptions.name);
117+
}
136118

137-
/**
138-
* Clears the stored data from the selected storage
139-
*
140-
* @public
141-
*/
142-
async function clearPersistedStorage() {
143-
await Settings.storageHandler.removeItem(storageName);
144-
}
119+
/**
120+
* Clears the stored data from the selected storage
121+
*
122+
* @public
123+
*/
124+
async function clearPersistedStorage() {
125+
await Settings.storageHandler.removeItem(storageName);
126+
}
145127

146128
/**
147129
* Creates the slice using the default options passed by the user.

0 commit comments

Comments
 (0)