Skip to content

Commit e6a1d48

Browse files
committed
Update createReducer to accept a lazy state init function
1 parent 1f1164b commit e6a1d48

File tree

3 files changed

+69
-13
lines changed

3 files changed

+69
-13
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

packages/toolkit/src/createReducer.ts

Lines changed: 30 additions & 12 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+
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
@@ -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 - The initial state that should be used when the reducer is called the first time. This may optionally be a "lazy state initializer" that returns the intended initial state value when called.
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.
@@ -180,31 +190,35 @@ const counterReducer = createReducer(0, {
180190
* @public
181191
*/
182192
export function createReducer<
183-
S,
193+
S extends NotFunction<any>,
184194
CR extends CaseReducers<S, any> = CaseReducers<S, any>
185195
>(
186-
initialState: S,
196+
initialState: S | (() => S),
187197
actionsMap: CR,
188198
actionMatchers?: ActionMatcherDescriptionCollection<S>,
189199
defaultCaseReducer?: CaseReducer<S>
190-
): Reducer<S>
200+
): ReducerWithInitialState<S>
191201

192-
export function createReducer<S>(
193-
initialState: S,
202+
export function createReducer<S extends NotFunction<any>>(
203+
initialState: S | (() => S),
194204
mapOrBuilderCallback:
195205
| CaseReducers<S, any>
196206
| ((builder: ActionReducerMapBuilder<S>) => void),
197207
actionMatchers: ReadonlyActionMatcherDescriptionCollection<S> = [],
198208
defaultCaseReducer?: CaseReducer<S>
199-
): Reducer<S> {
209+
): ReducerWithInitialState<S> {
200210
let [actionsMap, finalActionMatchers, finalDefaultCaseReducer] =
201211
typeof mapOrBuilderCallback === 'function'
202212
? executeReducerBuilderCallback(mapOrBuilderCallback)
203213
: [mapOrBuilderCallback, actionMatchers, defaultCaseReducer]
204214

205-
const frozenInitialState = createNextState(initialState, () => {})
215+
const getInitialState = () =>
216+
createNextState(
217+
isStateFunction(initialState) ? initialState() : initialState,
218+
() => {}
219+
)
206220

207-
return function (state = frozenInitialState, action): S {
221+
function reducer(state = getInitialState(), action: any): S {
208222
let caseReducers = [
209223
actionsMap[action.type],
210224
...finalActionMatchers
@@ -257,4 +271,8 @@ export function createReducer<S>(
257271
return previousState
258272
}, state)
259273
}
274+
275+
reducer.getInitialState = getInitialState
276+
277+
return reducer as ReducerWithInitialState<S>
260278
}

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

0 commit comments

Comments
 (0)