Skip to content

Commit 0a16fb5

Browse files
authored
Merge pull request #1662 from reduxjs/feature/state-lazy-init
2 parents 87d6e3a + eaca9de commit 0a16fb5

File tree

7 files changed

+154
-20
lines changed

7 files changed

+154
-20
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ temp/
1616
.tmp-projections
1717
build/
1818
.rts2*
19+
coverage/
1920

2021
typesversions
2122
.cache

docs/api/createReducer.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,21 @@ so we recommend the "builder callback" notation in most cases.
121121

122122
[params](docblock://createReducer.ts?token=createReducer&overload=1)
123123

124+
### Returns
125+
126+
The generated reducer function.
127+
128+
The reducer will have a `getInitialState` function attached that will return the initial state when called. This may be useful for tests or usage with React's `useReducer` hook:
129+
130+
```js
131+
const counterReducer = createReducer(0, {
132+
increment: (state, action) => state + action.payload,
133+
decrement: (state, action) => state - action.payload,
134+
})
135+
136+
console.log(counterReducer.getInitialState()) // 0
137+
```
138+
124139
### Example Usage
125140

126141
[examples](docblock://createReducer.ts?token=createReducer&overload=1)

docs/api/createSlice.mdx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ function createSlice({
7171

7272
The initial state value for this slice of state.
7373

74+
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`.
75+
7476
### `name`
7577

7678
A string name for this slice of state. Generated action type constants will use this as a prefix.
@@ -196,7 +198,8 @@ We recommend using the `builder callback` API as the default, especially if you
196198
name : string,
197199
reducer : ReducerFunction,
198200
actions : Record<string, ActionCreator>,
199-
caseReducers: Record<string, CaseReducer>
201+
caseReducers: Record<string, CaseReducer>.
202+
getInitialState: () => State
200203
}
201204
```
202205

packages/toolkit/src/createReducer.ts

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ export type CaseReducers<S, AS extends Actions> = {
6666
[T in keyof AS]: AS[T] extends Action ? CaseReducer<S, AS[T]> : void
6767
}
6868

69+
export type NotFunction<T> = T extends Function ? never : T
70+
71+
function isStateFunction<S>(x: unknown): x is () => S {
72+
return typeof x === 'function'
73+
}
74+
75+
export type ReducerWithInitialState<S extends NotFunction<any>> = Reducer<S> & {
76+
getInitialState: () => S
77+
}
78+
6979
/**
7080
* A utility function that allows defining a reducer as a mapping from action
7181
* type to *case reducer* functions that handle these action types. The
@@ -84,8 +94,8 @@ export type CaseReducers<S, AS extends Actions> = {
8494
* That builder provides `addCase`, `addMatcher` and `addDefaultCase` functions that may be
8595
* called to define what actions this reducer will handle.
8696
*
87-
* @param initialState - The initial state that should be used when the reducer is called the first time.
88-
* @param builderCallback - A callback that receives a *builder* object to define
97+
* @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`.
98+
* @param builderCallback - `(builder: Builder) => void` A callback that receives a *builder* object to define
8999
* case reducers via calls to `builder.addCase(actionCreatorOrType, reducer)`.
90100
* @example
91101
```ts
@@ -105,7 +115,7 @@ function isActionWithNumberPayload(
105115
return typeof action.payload === "number";
106116
}
107117
108-
createReducer(
118+
const reducer = createReducer(
109119
{
110120
counter: 0,
111121
sumOfNumberPayloads: 0,
@@ -130,10 +140,10 @@ createReducer(
130140
```
131141
* @public
132142
*/
133-
export function createReducer<S>(
134-
initialState: S,
143+
export function createReducer<S extends NotFunction<any>>(
144+
initialState: S | (() => S),
135145
builderCallback: (builder: ActionReducerMapBuilder<S>) => void
136-
): Reducer<S>
146+
): ReducerWithInitialState<S>
137147

138148
/**
139149
* A utility function that allows defining a reducer as a mapping from action
@@ -151,7 +161,7 @@ export function createReducer<S>(
151161
* This overload accepts an object where the keys are string action types, and the values
152162
* are case reducer functions to handle those action types.
153163
*
154-
* @param initialState - The initial state that should be used when the reducer is called the first time.
164+
* @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`.
155165
* @param actionsMap - An object mapping from action types to _case reducers_, each of which handles one specific action type.
156166
* @param actionMatchers - An array of matcher definitions in the form `{matcher, reducer}`.
157167
* All matching reducers will be executed in order, independently if a case reducer matched or not.
@@ -164,6 +174,14 @@ const counterReducer = createReducer(0, {
164174
increment: (state, action) => state + action.payload,
165175
decrement: (state, action) => state - action.payload
166176
})
177+
178+
// Alternately, use a "lazy initializer" to provide the initial state
179+
// (works with either form of createReducer)
180+
const initialState = () => 0
181+
const counterReducer = createReducer(initialState, {
182+
increment: (state, action) => state + action.payload,
183+
decrement: (state, action) => state - action.payload
184+
})
167185
```
168186
169187
* Action creators that were generated using [`createAction`](./createAction) may be used directly as the keys here, using computed property syntax:
@@ -180,31 +198,38 @@ const counterReducer = createReducer(0, {
180198
* @public
181199
*/
182200
export function createReducer<
183-
S,
201+
S extends NotFunction<any>,
184202
CR extends CaseReducers<S, any> = CaseReducers<S, any>
185203
>(
186-
initialState: S,
204+
initialState: S | (() => S),
187205
actionsMap: CR,
188206
actionMatchers?: ActionMatcherDescriptionCollection<S>,
189207
defaultCaseReducer?: CaseReducer<S>
190-
): Reducer<S>
208+
): ReducerWithInitialState<S>
191209

192-
export function createReducer<S>(
193-
initialState: S,
210+
export function createReducer<S extends NotFunction<any>>(
211+
initialState: S | (() => S),
194212
mapOrBuilderCallback:
195213
| CaseReducers<S, any>
196214
| ((builder: ActionReducerMapBuilder<S>) => void),
197215
actionMatchers: ReadonlyActionMatcherDescriptionCollection<S> = [],
198216
defaultCaseReducer?: CaseReducer<S>
199-
): Reducer<S> {
217+
): ReducerWithInitialState<S> {
200218
let [actionsMap, finalActionMatchers, finalDefaultCaseReducer] =
201219
typeof mapOrBuilderCallback === 'function'
202220
? executeReducerBuilderCallback(mapOrBuilderCallback)
203221
: [mapOrBuilderCallback, actionMatchers, defaultCaseReducer]
204222

205-
const frozenInitialState = createNextState(initialState, () => {})
223+
// Ensure the initial state gets frozen either way
224+
let getInitialState: () => S
225+
if (isStateFunction(initialState)) {
226+
getInitialState = () => createNextState(initialState(), () => {})
227+
} else {
228+
const frozenInitialState = createNextState(initialState, () => {})
229+
getInitialState = () => frozenInitialState
230+
}
206231

207-
return function (state = frozenInitialState, action): S {
232+
function reducer(state = getInitialState(), action: any): S {
208233
let caseReducers = [
209234
actionsMap[action.type],
210235
...finalActionMatchers
@@ -257,4 +282,8 @@ export function createReducer<S>(
257282
return previousState
258283
}, state)
259284
}
285+
286+
reducer.getInitialState = getInitialState
287+
288+
return reducer as ReducerWithInitialState<S>
260289
}

packages/toolkit/src/createSlice.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
} from './createAction'
99
import { createAction } from './createAction'
1010
import type { CaseReducer, CaseReducers } from './createReducer'
11-
import { createReducer } from './createReducer'
11+
import { createReducer, NotFunction } from './createReducer'
1212
import type { ActionReducerMapBuilder } from './mapBuilders'
1313
import { executeReducerBuilderCallback } from './mapBuilders'
1414
import type { NoInfer } from './tsHelpers'
@@ -53,6 +53,12 @@ export interface Slice<
5353
* This enables reuse and testing if they were defined inline when calling `createSlice`.
5454
*/
5555
caseReducers: SliceDefinedCaseReducers<CaseReducers>
56+
57+
/**
58+
* Provides access to the initial state value given to the slice.
59+
* If a lazy state initializer was provided, it will be called and a fresh value returned.
60+
*/
61+
getInitialState: () => State
5662
}
5763

5864
/**
@@ -71,9 +77,9 @@ export interface CreateSliceOptions<
7177
name: Name
7278

7379
/**
74-
* The initial state to be returned by the slice reducer.
80+
* 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`.
7581
*/
76-
initialState: State
82+
initialState: State | (() => State)
7783

7884
/**
7985
* A mapping from action types to action-type-specific *case reducer*
@@ -301,5 +307,6 @@ export function createSlice<
301307
reducer,
302308
actions: actionCreators as any,
303309
caseReducers: sliceCaseReducersByName as any,
310+
getInitialState: reducer.getInitialState,
304311
}
305312
}

packages/toolkit/src/tests/createReducer.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,10 @@ describe('createReducer', () => {
9898
test('Freezes initial state', () => {
9999
const initialState = [{ text: 'Buy milk' }]
100100
const todosReducer = createReducer(initialState, {})
101+
const frozenInitialState = todosReducer(undefined, { type: 'dummy' })
101102

102-
const mutateStateOutsideReducer = () => (initialState[0].text = 'edited')
103+
const mutateStateOutsideReducer = () =>
104+
(frozenInitialState[0].text = 'edited')
103105
expect(mutateStateOutsideReducer).toThrowError(
104106
/Cannot assign to read only property/
105107
)
@@ -132,6 +134,41 @@ describe('createReducer', () => {
132134
behavesLikeReducer(todosReducer)
133135
})
134136

137+
describe('Accepts a lazy state init function to generate initial state', () => {
138+
const addTodo: AddTodoReducer = (state, action) => {
139+
const { newTodo } = action.payload
140+
state.push({ ...newTodo, completed: false })
141+
}
142+
143+
const toggleTodo: ToggleTodoReducer = (state, action) => {
144+
const { index } = action.payload
145+
const todo = state[index]
146+
todo.completed = !todo.completed
147+
}
148+
149+
const lazyStateInit = () => [] as TodoState
150+
151+
const todosReducer = createReducer(lazyStateInit, {
152+
ADD_TODO: addTodo,
153+
TOGGLE_TODO: toggleTodo,
154+
})
155+
156+
behavesLikeReducer(todosReducer)
157+
158+
it('Should only call the init function when `undefined` state is passed in', () => {
159+
const spy = jest.fn().mockReturnValue(42)
160+
161+
const dummyReducer = createReducer(spy, {})
162+
expect(spy).not.toHaveBeenCalled()
163+
164+
dummyReducer(123, { type: 'dummy' })
165+
expect(spy).not.toHaveBeenCalled()
166+
167+
const initialState = dummyReducer(undefined, { type: 'dummy' })
168+
expect(spy).toHaveBeenCalledTimes(1)
169+
})
170+
})
171+
135172
describe('given draft state from immer', () => {
136173
const addTodo: AddTodoReducer = (state, action) => {
137174
const { newTodo } = action.payload

packages/toolkit/src/tests/createSlice.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,48 @@ describe('createSlice', () => {
6363
expect(caseReducers.increment).toBeTruthy()
6464
expect(typeof caseReducers.increment).toBe('function')
6565
})
66+
67+
it('getInitialState should return the state', () => {
68+
const initialState = 42
69+
const slice = createSlice({
70+
name: 'counter',
71+
initialState,
72+
reducers: {},
73+
})
74+
75+
expect(slice.getInitialState()).toBe(initialState)
76+
})
77+
})
78+
79+
describe('when initialState is a function', () => {
80+
const initialState = () => ({ user: '' })
81+
82+
const { actions, reducer } = createSlice({
83+
reducers: {
84+
setUserName: (state, action) => {
85+
state.user = action.payload
86+
},
87+
},
88+
initialState,
89+
name: 'user',
90+
})
91+
92+
it('should set the username', () => {
93+
expect(reducer(undefined, actions.setUserName('eric'))).toEqual({
94+
user: 'eric',
95+
})
96+
})
97+
98+
it('getInitialState should return the state', () => {
99+
const initialState = () => 42
100+
const slice = createSlice({
101+
name: 'counter',
102+
initialState,
103+
reducers: {},
104+
})
105+
106+
expect(slice.getInitialState()).toBe(42)
107+
})
66108
})
67109

68110
describe('when mutating state object', () => {

0 commit comments

Comments
 (0)