Skip to content

Commit afa762d

Browse files
[entityAdapter] handle draft states in selectors (#815)
Co-authored-by: Mark Erikson <[email protected]>
1 parent e8c2c4b commit afa762d

6 files changed

+120
-9
lines changed

docs/api/createSelector.mdx

+41
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,44 @@ For more details on using `createSelector`, see:
1919
> **Note**: Prior to v0.7, RTK re-exported `createSelector` from [`selectorator`](https://github.com/planttheidea/selectorator), which
2020
> allowed using string keypaths as input selectors. This was removed, as it ultimately did not provide enough benefits, and
2121
> the string keypaths made static typing for selectors difficult.
22+
23+
# `createDraftSafeSelector`
24+
25+
In general, we recommend against using selectors inside of reducers:
26+
27+
- Selectors typically expect the entire Redux state object as an argument, while slice reducers only have access to a specific subset of the entire Redux state
28+
- Reselect's `createSelector` relies on reference comparisons to determine if inputs have changed, and if an Immer Proxy-wrapped draft value is passed in to a selector, the selector may see the same reference and think nothing has changed.
29+
30+
However, some users have requested the ability to create selectors that will work correctly inside of Immer-powered reducers. One use case for this might be collecting an ordered set of items when using `createEntityAdapter, such as`const orderedTodos = todosSelectors.selectAll(todosState)`, and then using`orderedTodos` in the rest of the reducer logic.
31+
32+
Besides re-exporting `createSelector`, RTK also exports a wrapped version of `createSelector` named `createDraftSafeSelector` that allows you to create selectors that can safely be used inside of `createReducer` and `createSlice` reducers with Immer-powered mutable logic. When used with plain state values, the selector will still memoize normally based on the inputs. But, when used with Immer draft values, the selector will err on the side of recalculating the results, just to be safe.
33+
34+
All selectors created by `entityAdapter.getSelectors` are "draft safe" selectors by default.
35+
36+
Example:
37+
38+
```js
39+
const selectSelf = (state: State) => state
40+
const unsafeSelector = createSelector(selectSelf, state => state.value)
41+
const draftSafeSelector = createDraftSafeSelector(
42+
selectSelf,
43+
state => state.value
44+
)
45+
46+
// in your reducer:
47+
48+
state.value = 1
49+
50+
const unsafe1 = unsafeSelector(state)
51+
const safe1 = draftSafeSelector(state)
52+
53+
state.value = 2
54+
55+
const unsafe2 = unsafeSelector(state)
56+
const safe2 = draftSafeSelector(state)
57+
```
58+
59+
After executing that, `unsafe1` and `unsafe2` will be of the same value, because the memoized selector was
60+
executed on the same object - but `safe2` will actually be different from `safe1` (with the updated value of `2`),
61+
because the safe selector detected that it was executed on a Immer draft object and recalculated using the current
62+
value instead of returning a cached value.

etc/redux-toolkit.api.md

+3
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ export function createAction<PA extends PrepareAction<any>, T extends string = s
138138
// @public (undocumented)
139139
export function createAsyncThunk<Returned, ThunkArg = void, ThunkApiConfig extends AsyncThunkConfig = {}>(typePrefix: string, payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>, options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>): AsyncThunk<Returned, ThunkArg, ThunkApiConfig>;
140140

141+
// @public
142+
export const createDraftSafeSelector: typeof createSelector;
143+
141144
// @public (undocumented)
142145
export function createEntityAdapter<T>(options?: {
143146
selectId?: IdSelector<T>;

src/createDraftSafeSelector.test.ts

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { createSelector } from 'reselect'
2+
import { createDraftSafeSelector } from './createDraftSafeSelector'
3+
import { produce } from 'immer'
4+
5+
type State = { value: number }
6+
const selectSelf = (state: State) => state
7+
8+
test('handles normal values correctly', () => {
9+
const unsafeSelector = createSelector(selectSelf, x => x.value)
10+
const draftSafeSelector = createDraftSafeSelector(selectSelf, x => x.value)
11+
12+
let state = { value: 1 }
13+
expect(unsafeSelector(state)).toBe(1)
14+
expect(draftSafeSelector(state)).toBe(1)
15+
16+
state = { value: 2 }
17+
expect(unsafeSelector(state)).toBe(2)
18+
expect(draftSafeSelector(state)).toBe(2)
19+
})
20+
21+
test('handles drafts correctly', () => {
22+
const unsafeSelector = createSelector(selectSelf, state => state.value)
23+
const draftSafeSelector = createDraftSafeSelector(
24+
selectSelf,
25+
state => state.value
26+
)
27+
28+
produce({ value: 1 }, state => {
29+
expect(unsafeSelector(state)).toBe(1)
30+
expect(draftSafeSelector(state)).toBe(1)
31+
32+
state.value = 2
33+
34+
expect(unsafeSelector(state)).toBe(1)
35+
expect(draftSafeSelector(state)).toBe(2)
36+
})
37+
})

src/createDraftSafeSelector.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { current, isDraft } from 'immer'
2+
import { createSelector } from 'reselect'
3+
4+
/**
5+
* "Draft-Safe" version of `reselect`'s `createSelector`:
6+
* If an `immer`-drafted object is passed into the resulting selector's first argument,
7+
* the selector will act on the current draft value, instead of returning a cached value
8+
* that might be possibly outdated if the draft has been modified since.
9+
* @public
10+
*/
11+
export const createDraftSafeSelector: typeof createSelector = (
12+
...args: unknown[]
13+
) => {
14+
const selector = (createSelector as any)(...args)
15+
const wrappedSelector = (value: unknown, ...rest: unknown[]) =>
16+
selector(isDraft(value) ? current(value) : value, ...rest)
17+
return wrappedSelector as any
18+
}

src/entities/state_selectors.ts

+20-9
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createSelector } from 'reselect'
1+
import { createDraftSafeSelector } from '../createDraftSafeSelector'
22
import { EntityState, EntitySelectors, Dictionary, EntityId } from './models'
33

44
export function createSelectorsFactory<T>() {
@@ -13,7 +13,7 @@ export function createSelectorsFactory<T>() {
1313

1414
const selectEntities = (state: EntityState<T>) => state.entities
1515

16-
const selectAll = createSelector(
16+
const selectAll = createDraftSafeSelector(
1717
selectIds,
1818
selectEntities,
1919
(ids: T[], entities: Dictionary<T>): any =>
@@ -24,26 +24,37 @@ export function createSelectorsFactory<T>() {
2424

2525
const selectById = (entities: Dictionary<T>, id: EntityId) => entities[id]
2626

27-
const selectTotal = createSelector(selectIds, ids => ids.length)
27+
const selectTotal = createDraftSafeSelector(selectIds, ids => ids.length)
2828

2929
if (!selectState) {
3030
return {
3131
selectIds,
3232
selectEntities,
3333
selectAll,
3434
selectTotal,
35-
selectById: createSelector(selectEntities, selectId, selectById)
35+
selectById: createDraftSafeSelector(
36+
selectEntities,
37+
selectId,
38+
selectById
39+
)
3640
}
3741
}
3842

39-
const selectGlobalizedEntities = createSelector(selectState, selectEntities)
43+
const selectGlobalizedEntities = createDraftSafeSelector(
44+
selectState,
45+
selectEntities
46+
)
4047

4148
return {
42-
selectIds: createSelector(selectState, selectIds),
49+
selectIds: createDraftSafeSelector(selectState, selectIds),
4350
selectEntities: selectGlobalizedEntities,
44-
selectAll: createSelector(selectState, selectAll),
45-
selectTotal: createSelector(selectState, selectTotal),
46-
selectById: createSelector(selectGlobalizedEntities, selectId, selectById)
51+
selectAll: createDraftSafeSelector(selectState, selectAll),
52+
selectTotal: createDraftSafeSelector(selectState, selectTotal),
53+
selectById: createDraftSafeSelector(
54+
selectGlobalizedEntities,
55+
selectId,
56+
selectById
57+
)
4758
}
4859
}
4960

src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
OutputSelector,
1010
ParametricSelector
1111
} from 'reselect'
12+
export { createDraftSafeSelector } from './createDraftSafeSelector'
1213
export { ThunkAction, ThunkDispatch } from 'redux-thunk'
1314

1415
// We deliberately enable Immer's ES5 support, on the grounds that

0 commit comments

Comments
 (0)