diff --git a/docs/api/createSelector.mdx b/docs/api/createSelector.mdx index fa93dc9c63..b06b114847 100644 --- a/docs/api/createSelector.mdx +++ b/docs/api/createSelector.mdx @@ -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. diff --git a/etc/redux-toolkit.api.md b/etc/redux-toolkit.api.md index d0bd3cf298..8468a94354 100644 --- a/etc/redux-toolkit.api.md +++ b/etc/redux-toolkit.api.md @@ -131,6 +131,9 @@ export function createAction, T extends string = s // @public (undocumented) export function createAsyncThunk(typePrefix: string, payloadCreator: AsyncThunkPayloadCreator, options?: AsyncThunkOptions): AsyncThunk; +// @public +export const createDraftSafeSelector: typeof createSelector; + // @public (undocumented) export function createEntityAdapter(options?: { selectId?: IdSelector; diff --git a/src/createDraftSafeSelector.test.ts b/src/createDraftSafeSelector.test.ts new file mode 100644 index 0000000000..0451806456 --- /dev/null +++ b/src/createDraftSafeSelector.test.ts @@ -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) + }) +}) diff --git a/src/createDraftSafeSelector.ts b/src/createDraftSafeSelector.ts new file mode 100644 index 0000000000..b1858af2e7 --- /dev/null +++ b/src/createDraftSafeSelector.ts @@ -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 +} diff --git a/src/entities/state_selectors.ts b/src/entities/state_selectors.ts index d7370c879a..5b10a37159 100644 --- a/src/entities/state_selectors.ts +++ b/src/entities/state_selectors.ts @@ -1,4 +1,4 @@ -import { createSelector } from 'reselect' +import { createDraftSafeSelector } from '../createDraftSafeSelector' import { EntityState, EntitySelectors, Dictionary, EntityId } from './models' export function createSelectorsFactory() { @@ -13,7 +13,7 @@ export function createSelectorsFactory() { const selectEntities = (state: EntityState) => state.entities - const selectAll = createSelector( + const selectAll = createDraftSafeSelector( selectIds, selectEntities, (ids: T[], entities: Dictionary): any => @@ -24,7 +24,7 @@ export function createSelectorsFactory() { const selectById = (entities: Dictionary, id: EntityId) => entities[id] - const selectTotal = createSelector(selectIds, ids => ids.length) + const selectTotal = createDraftSafeSelector(selectIds, ids => ids.length) if (!selectState) { return { @@ -32,18 +32,29 @@ export function createSelectorsFactory() { 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 + ) } } diff --git a/src/index.ts b/src/index.ts index 69f6fc6543..0b54870421 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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