Skip to content

[entityAdapter] handle draft states in selectors #815

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/api/createSelector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,44 @@ For more details on using `createSelector`, see:
> **Note**: Prior to v0.7, RTK re-exported `createSelector` from [`selectorator`](https://github.com/planttheidea/selectorator), which
> allowed using string keypaths as input selectors. This was removed, as it ultimately did not provide enough benefits, and
> the string keypaths made static typing for selectors difficult.

# `createDraftSafeSelector`

In general, we recommend against using selectors inside of reducers:

- 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
- 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.

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.

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.

All selectors created by `entityAdapter.getSelectors` are "draft safe" selectors by default.

Example:

```js
const selectSelf = (state: State) => state
const unsafeSelector = createSelector(selectSelf, state => state.value)
const draftSafeSelector = createDraftSafeSelector(
selectSelf,
state => state.value
)

// in your reducer:

state.value = 1

const unsafe1 = unsafeSelector(state)
const safe1 = draftSafeSelector(state)

state.value = 2

const unsafe2 = unsafeSelector(state)
const safe2 = draftSafeSelector(state)
```

After executing that, `unsafe1` and `unsafe2` will be of the same value, because the memoized selector was
executed on the same object - but `safe2` will actually be different from `safe1` (with the updated value of `2`),
because the safe selector detected that it was executed on a Immer draft object and recalculated using the current
value instead of returning a cached value.
3 changes: 3 additions & 0 deletions etc/redux-toolkit.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ export function createAction<PA extends PrepareAction<any>, T extends string = s
// @public (undocumented)
export function createAsyncThunk<Returned, ThunkArg = void, ThunkApiConfig extends AsyncThunkConfig = {}>(typePrefix: string, payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>, options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>): AsyncThunk<Returned, ThunkArg, ThunkApiConfig>;

// @public
export const createDraftSafeSelector: typeof createSelector;

// @public (undocumented)
export function createEntityAdapter<T>(options?: {
selectId?: IdSelector<T>;
Expand Down
37 changes: 37 additions & 0 deletions src/createDraftSafeSelector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { createSelector } from 'reselect'
import { createDraftSafeSelector } from './createDraftSafeSelector'
import { produce } from 'immer'

type State = { value: number }
const selectSelf = (state: State) => state

test('handles normal values correctly', () => {
const unsafeSelector = createSelector(selectSelf, x => x.value)
const draftSafeSelector = createDraftSafeSelector(selectSelf, x => x.value)

let state = { value: 1 }
expect(unsafeSelector(state)).toBe(1)
expect(draftSafeSelector(state)).toBe(1)

state = { value: 2 }
expect(unsafeSelector(state)).toBe(2)
expect(draftSafeSelector(state)).toBe(2)
})

test('handles drafts correctly', () => {
const unsafeSelector = createSelector(selectSelf, state => state.value)
const draftSafeSelector = createDraftSafeSelector(
selectSelf,
state => state.value
)

produce({ value: 1 }, state => {
expect(unsafeSelector(state)).toBe(1)
expect(draftSafeSelector(state)).toBe(1)

state.value = 2

expect(unsafeSelector(state)).toBe(1)
expect(draftSafeSelector(state)).toBe(2)
})
})
18 changes: 18 additions & 0 deletions src/createDraftSafeSelector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { current, isDraft } from 'immer'
import { createSelector } from 'reselect'

/**
* "Draft-Safe" version of `reselect`'s `createSelector`:
* If an `immer`-drafted object is passed into the resulting selector's first argument,
* the selector will act on the current draft value, instead of returning a cached value
* that might be possibly outdated if the draft has been modified since.
* @public
*/
export const createDraftSafeSelector: typeof createSelector = (
...args: unknown[]
) => {
const selector = (createSelector as any)(...args)
const wrappedSelector = (value: unknown, ...rest: unknown[]) =>
selector(isDraft(value) ? current(value) : value, ...rest)
return wrappedSelector as any
}
29 changes: 20 additions & 9 deletions src/entities/state_selectors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createDraftSafeSelector } from '../createDraftSafeSelector'
import { EntityState, EntitySelectors, Dictionary, EntityId } from './models'

export function createSelectorsFactory<T>() {
Expand All @@ -13,7 +13,7 @@ export function createSelectorsFactory<T>() {

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

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

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

const selectTotal = createSelector(selectIds, ids => ids.length)
const selectTotal = createDraftSafeSelector(selectIds, ids => ids.length)

if (!selectState) {
return {
selectIds,
selectEntities,
selectAll,
selectTotal,
selectById: createSelector(selectEntities, selectId, selectById)
selectById: createDraftSafeSelector(
selectEntities,
selectId,
selectById
)
}
}

const selectGlobalizedEntities = createSelector(selectState, selectEntities)
const selectGlobalizedEntities = createDraftSafeSelector(
selectState,
selectEntities
)

return {
selectIds: createSelector(selectState, selectIds),
selectIds: createDraftSafeSelector(selectState, selectIds),
selectEntities: selectGlobalizedEntities,
selectAll: createSelector(selectState, selectAll),
selectTotal: createSelector(selectState, selectTotal),
selectById: createSelector(selectGlobalizedEntities, selectId, selectById)
selectAll: createDraftSafeSelector(selectState, selectAll),
selectTotal: createDraftSafeSelector(selectState, selectTotal),
selectById: createDraftSafeSelector(
selectGlobalizedEntities,
selectId,
selectById
)
}
}

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
OutputSelector,
ParametricSelector
} from 'reselect'
export { createDraftSafeSelector } from './createDraftSafeSelector'
export { ThunkAction, ThunkDispatch } from 'redux-thunk'

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