Skip to content

Commit a60a105

Browse files
ben.durrantmarkerikson
authored andcommitted
Experiment with selector stability idea.
1 parent b5a6d14 commit a60a105

File tree

5 files changed

+90
-11
lines changed

5 files changed

+90
-11
lines changed

src/components/Context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createContext } from 'react'
22
import type { Action, AnyAction, Store } from 'redux'
33
import type { Subscription } from '../utils/Subscription'
4+
import { StabilityCheck } from '../hooks/useSelector'
45

56
export interface ReactReduxContextValue<
67
SS = any,
@@ -9,6 +10,7 @@ export interface ReactReduxContextValue<
910
store: Store<SS, A>
1011
subscription: Subscription
1112
getServerState?: () => SS
13+
stabilityCheck: StabilityCheck
1214
}
1315

1416
export const ReactReduxContext =

src/components/Provider.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ReactReduxContext, ReactReduxContextValue } from './Context'
33
import { createSubscription } from '../utils/Subscription'
44
import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect'
55
import { Action, AnyAction, Store } from 'redux'
6+
import { StabilityCheck } from '../hooks/useSelector'
67

78
export interface ProviderProps<A extends Action = AnyAction, S = unknown> {
89
/**
@@ -21,6 +22,8 @@ export interface ProviderProps<A extends Action = AnyAction, S = unknown> {
2122
* Initial value doesn't matter, as it is overwritten with the internal state of Provider.
2223
*/
2324
context?: Context<ReactReduxContextValue<S, A>>
25+
26+
stabilityCheck?: StabilityCheck
2427
children: ReactNode
2528
}
2629

@@ -29,15 +32,17 @@ function Provider<A extends Action = AnyAction, S = unknown>({
2932
context,
3033
children,
3134
serverState,
35+
stabilityCheck = 'once',
3236
}: ProviderProps<A, S>) {
3337
const contextValue = useMemo(() => {
3438
const subscription = createSubscription(store)
3539
return {
3640
store,
3741
subscription,
3842
getServerState: serverState ? () => serverState : undefined,
43+
stabilityCheck,
3944
}
40-
}, [store, serverState])
45+
}, [store, serverState, stabilityCheck])
4146

4247
const previousState = useMemo(() => store.getState(), [store])
4348

src/hooks/useSelector.ts

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useDebugValue } from 'react'
1+
import { useCallback, useDebugValue, useRef } from 'react'
22

33
import {
44
createReduxContextHook,
@@ -9,6 +9,24 @@ import type { EqualityFn, NoInfer } from '../types'
99
import type { uSESWS } from '../utils/useSyncExternalStore'
1010
import { notInitialized } from '../utils/useSyncExternalStore'
1111

12+
export type StabilityCheck = 'never' | 'once' | 'always'
13+
14+
export interface UseSelectorOptions<Selected = unknown> {
15+
equalityFn?: EqualityFn<Selected>
16+
stabilityCheck?: StabilityCheck
17+
}
18+
19+
interface UseSelector {
20+
<TState = unknown, Selected = unknown>(
21+
selector: (state: TState) => Selected,
22+
equalityFn?: EqualityFn<Selected>
23+
): Selected
24+
<TState = unknown, Selected = unknown>(
25+
selector: (state: TState) => Selected,
26+
options?: UseSelectorOptions<Selected>
27+
): Selected
28+
}
29+
1230
let useSyncExternalStoreWithSelector = notInitialized as uSESWS
1331
export const initializeUseSelector = (fn: uSESWS) => {
1432
useSyncExternalStoreWithSelector = fn
@@ -22,21 +40,22 @@ const refEquality: EqualityFn<any> = (a, b) => a === b
2240
* @param {React.Context} [context=ReactReduxContext] Context passed to your `<Provider>`.
2341
* @returns {Function} A `useSelector` hook bound to the specified context.
2442
*/
25-
export function createSelectorHook(
26-
context = ReactReduxContext
27-
): <TState = unknown, Selected = unknown>(
28-
selector: (state: TState) => Selected,
29-
equalityFn?: EqualityFn<Selected>
30-
) => Selected {
43+
export function createSelectorHook(context = ReactReduxContext): UseSelector {
3144
const useReduxContext =
3245
context === ReactReduxContext
3346
? useDefaultReduxContext
3447
: createReduxContextHook(context)
3548

3649
return function useSelector<TState, Selected extends unknown>(
3750
selector: (state: TState) => Selected,
38-
equalityFn: EqualityFn<NoInfer<Selected>> = refEquality
51+
equalityFnOrOptions:
52+
| EqualityFn<NoInfer<Selected>>
53+
| UseSelectorOptions<NoInfer<Selected>> = {}
3954
): Selected {
55+
const { equalityFn = refEquality, stabilityCheck = undefined } =
56+
typeof equalityFnOrOptions === 'function'
57+
? { equalityFn: equalityFnOrOptions }
58+
: equalityFnOrOptions
4059
if (process.env.NODE_ENV !== 'production') {
4160
if (!selector) {
4261
throw new Error(`You must pass a selector to useSelector`)
@@ -51,13 +70,56 @@ export function createSelectorHook(
5170
}
5271
}
5372

54-
const { store, subscription, getServerState } = useReduxContext()!
73+
const {
74+
store,
75+
subscription,
76+
getServerState,
77+
stabilityCheck: globalStabilityCheck,
78+
} = useReduxContext()!
79+
80+
const firstRun = useRef(true)
81+
82+
const wrappedSelector = useCallback<typeof selector>(
83+
{
84+
[selector.name](state: TState) {
85+
const selected = selector(state)
86+
const finalStabilityCheck =
87+
// are we safe to use ?? here?
88+
typeof stabilityCheck === 'undefined'
89+
? globalStabilityCheck
90+
: stabilityCheck
91+
if (
92+
process.env.NODE_ENV !== 'production' &&
93+
(finalStabilityCheck === 'always' ||
94+
(finalStabilityCheck === 'once' && firstRun.current))
95+
) {
96+
const toCompare = selector(state)
97+
if (!equalityFn(selected, toCompare)) {
98+
console.warn(
99+
'Selector ' +
100+
(selector.name || 'unknown') +
101+
' returned a different result when called with the same parameters. This can lead to unnecessary rerenders.' +
102+
'\n Selectors that return a new reference (such as an object or an array) should be memoized: https://redux.js.org/usage/deriving-data-selectors#optimizing-selectors-with-memoization',
103+
{
104+
state,
105+
selected,
106+
selected2: toCompare,
107+
}
108+
)
109+
}
110+
firstRun.current = false
111+
}
112+
return selected
113+
},
114+
}[selector.name],
115+
[selector, globalStabilityCheck, stabilityCheck]
116+
)
55117

56118
const selectedState = useSyncExternalStoreWithSelector(
57119
subscription.addNestedSub,
58120
store.getState,
59121
getServerState || store.getState,
60-
selector,
122+
wrappedSelector,
61123
equalityFn
62124
)
63125

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type { NonReactStatics } from 'hoist-non-react-statics'
1111

1212
import type { ConnectProps } from './components/connect'
1313

14+
import { UseSelectorOptions } from './hooks/useSelector'
15+
1416
export type FixTypeLater = any
1517

1618
export type EqualityFn<T> = (a: T, b: T) => boolean
@@ -167,6 +169,10 @@ export interface TypedUseSelectorHook<TState> {
167169
selector: (state: TState) => TSelected,
168170
equalityFn?: EqualityFn<NoInfer<TSelected>>
169171
): TSelected
172+
<Selected = unknown>(
173+
selector: (state: TState) => Selected,
174+
options?: UseSelectorOptions<Selected>
175+
): Selected
170176
}
171177

172178
export type NoInfer<T> = [T][T extends any ? 0 : never]

test/typetests/hooks.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ function testUseSelector() {
168168
})
169169

170170
const correctlyInferred: State = useSelector(selector, shallowEqual)
171+
const correctlyInferred2: State = useSelector(selector, {
172+
equalityFn: shallowEqual,
173+
stabilityCheck: 'never',
174+
})
171175
// @ts-expect-error
172176
const inferredTypeIsNotString: string = useSelector(selector, shallowEqual)
173177

0 commit comments

Comments
 (0)