From a60a105301eb5a5ef2aadd6ada72d6dcee990418 Mon Sep 17 00:00:00 2001 From: "ben.durrant" Date: Mon, 15 May 2023 17:27:32 +0100 Subject: [PATCH 1/8] Experiment with selector stability idea. --- src/components/Context.ts | 2 + src/components/Provider.tsx | 7 +++- src/hooks/useSelector.ts | 82 ++++++++++++++++++++++++++++++++----- src/types.ts | 6 +++ test/typetests/hooks.tsx | 4 ++ 5 files changed, 90 insertions(+), 11 deletions(-) diff --git a/src/components/Context.ts b/src/components/Context.ts index 821ca6af3..cfba5537d 100644 --- a/src/components/Context.ts +++ b/src/components/Context.ts @@ -1,6 +1,7 @@ import { createContext } from 'react' import type { Action, AnyAction, Store } from 'redux' import type { Subscription } from '../utils/Subscription' +import { StabilityCheck } from '../hooks/useSelector' export interface ReactReduxContextValue< SS = any, @@ -9,6 +10,7 @@ export interface ReactReduxContextValue< store: Store subscription: Subscription getServerState?: () => SS + stabilityCheck: StabilityCheck } export const ReactReduxContext = diff --git a/src/components/Provider.tsx b/src/components/Provider.tsx index f3ffb2da1..09637f1f4 100644 --- a/src/components/Provider.tsx +++ b/src/components/Provider.tsx @@ -3,6 +3,7 @@ import { ReactReduxContext, ReactReduxContextValue } from './Context' import { createSubscription } from '../utils/Subscription' import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' import { Action, AnyAction, Store } from 'redux' +import { StabilityCheck } from '../hooks/useSelector' export interface ProviderProps { /** @@ -21,6 +22,8 @@ export interface ProviderProps { * Initial value doesn't matter, as it is overwritten with the internal state of Provider. */ context?: Context> + + stabilityCheck?: StabilityCheck children: ReactNode } @@ -29,6 +32,7 @@ function Provider({ context, children, serverState, + stabilityCheck = 'once', }: ProviderProps) { const contextValue = useMemo(() => { const subscription = createSubscription(store) @@ -36,8 +40,9 @@ function Provider({ store, subscription, getServerState: serverState ? () => serverState : undefined, + stabilityCheck, } - }, [store, serverState]) + }, [store, serverState, stabilityCheck]) const previousState = useMemo(() => store.getState(), [store]) diff --git a/src/hooks/useSelector.ts b/src/hooks/useSelector.ts index 7388044d2..eb0b5b797 100644 --- a/src/hooks/useSelector.ts +++ b/src/hooks/useSelector.ts @@ -1,4 +1,4 @@ -import { useDebugValue } from 'react' +import { useCallback, useDebugValue, useRef } from 'react' import { createReduxContextHook, @@ -9,6 +9,24 @@ import type { EqualityFn, NoInfer } from '../types' import type { uSESWS } from '../utils/useSyncExternalStore' import { notInitialized } from '../utils/useSyncExternalStore' +export type StabilityCheck = 'never' | 'once' | 'always' + +export interface UseSelectorOptions { + equalityFn?: EqualityFn + stabilityCheck?: StabilityCheck +} + +interface UseSelector { + ( + selector: (state: TState) => Selected, + equalityFn?: EqualityFn + ): Selected + ( + selector: (state: TState) => Selected, + options?: UseSelectorOptions + ): Selected +} + let useSyncExternalStoreWithSelector = notInitialized as uSESWS export const initializeUseSelector = (fn: uSESWS) => { useSyncExternalStoreWithSelector = fn @@ -22,12 +40,7 @@ const refEquality: EqualityFn = (a, b) => a === b * @param {React.Context} [context=ReactReduxContext] Context passed to your ``. * @returns {Function} A `useSelector` hook bound to the specified context. */ -export function createSelectorHook( - context = ReactReduxContext -): ( - selector: (state: TState) => Selected, - equalityFn?: EqualityFn -) => Selected { +export function createSelectorHook(context = ReactReduxContext): UseSelector { const useReduxContext = context === ReactReduxContext ? useDefaultReduxContext @@ -35,8 +48,14 @@ export function createSelectorHook( return function useSelector( selector: (state: TState) => Selected, - equalityFn: EqualityFn> = refEquality + equalityFnOrOptions: + | EqualityFn> + | UseSelectorOptions> = {} ): Selected { + const { equalityFn = refEquality, stabilityCheck = undefined } = + typeof equalityFnOrOptions === 'function' + ? { equalityFn: equalityFnOrOptions } + : equalityFnOrOptions if (process.env.NODE_ENV !== 'production') { if (!selector) { throw new Error(`You must pass a selector to useSelector`) @@ -51,13 +70,56 @@ export function createSelectorHook( } } - const { store, subscription, getServerState } = useReduxContext()! + const { + store, + subscription, + getServerState, + stabilityCheck: globalStabilityCheck, + } = useReduxContext()! + + const firstRun = useRef(true) + + const wrappedSelector = useCallback( + { + [selector.name](state: TState) { + const selected = selector(state) + const finalStabilityCheck = + // are we safe to use ?? here? + typeof stabilityCheck === 'undefined' + ? globalStabilityCheck + : stabilityCheck + if ( + process.env.NODE_ENV !== 'production' && + (finalStabilityCheck === 'always' || + (finalStabilityCheck === 'once' && firstRun.current)) + ) { + const toCompare = selector(state) + if (!equalityFn(selected, toCompare)) { + console.warn( + 'Selector ' + + (selector.name || 'unknown') + + ' returned a different result when called with the same parameters. This can lead to unnecessary rerenders.' + + '\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', + { + state, + selected, + selected2: toCompare, + } + ) + } + firstRun.current = false + } + return selected + }, + }[selector.name], + [selector, globalStabilityCheck, stabilityCheck] + ) const selectedState = useSyncExternalStoreWithSelector( subscription.addNestedSub, store.getState, getServerState || store.getState, - selector, + wrappedSelector, equalityFn ) diff --git a/src/types.ts b/src/types.ts index 90ecebe8d..90c24b951 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,8 @@ import type { NonReactStatics } from 'hoist-non-react-statics' import type { ConnectProps } from './components/connect' +import { UseSelectorOptions } from './hooks/useSelector' + export type FixTypeLater = any export type EqualityFn = (a: T, b: T) => boolean @@ -167,6 +169,10 @@ export interface TypedUseSelectorHook { selector: (state: TState) => TSelected, equalityFn?: EqualityFn> ): TSelected + ( + selector: (state: TState) => Selected, + options?: UseSelectorOptions + ): Selected } export type NoInfer = [T][T extends any ? 0 : never] diff --git a/test/typetests/hooks.tsx b/test/typetests/hooks.tsx index 9bbce00e4..d0092856d 100644 --- a/test/typetests/hooks.tsx +++ b/test/typetests/hooks.tsx @@ -168,6 +168,10 @@ function testUseSelector() { }) const correctlyInferred: State = useSelector(selector, shallowEqual) + const correctlyInferred2: State = useSelector(selector, { + equalityFn: shallowEqual, + stabilityCheck: 'never', + }) // @ts-expect-error const inferredTypeIsNotString: string = useSelector(selector, shallowEqual) From 86716dbaaf6a8b758b1ea73bfe26e154533fa0bd Mon Sep 17 00:00:00 2001 From: "ben.durrant" Date: Mon, 15 May 2023 18:21:42 +0100 Subject: [PATCH 2/8] fix tests --- test/hooks/useSelector.spec.tsx | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/test/hooks/useSelector.spec.tsx b/test/hooks/useSelector.spec.tsx index 9d406d875..bc08f93a3 100644 --- a/test/hooks/useSelector.spec.tsx +++ b/test/hooks/useSelector.spec.tsx @@ -7,10 +7,11 @@ import React, { useState, useContext, } from 'react' -import { createStore } from 'redux' +import { Action, createStore } from 'redux' import * as rtl from '@testing-library/react' import { - Provider as ProviderMock, + Provider, + ProviderProps, useSelector, useDispatch, shallowEqual, @@ -26,6 +27,15 @@ import type { import type { FunctionComponent, DispatchWithoutAction, ReactNode } from 'react' import type { Store, AnyAction } from 'redux' +// most of these tests depend on selectors being run once, which stabilityCheck doesn't do +// rather than specify it every time, let's make a new "default" here +function ProviderMock = AnyAction, S = unknown>({ + stabilityCheck = 'never', + ...props +}: ProviderProps) { + return +} + const IS_REACT_18 = React.version.startsWith('18') describe('React', () => { @@ -349,7 +359,7 @@ describe('React', () => { numCalls += 1 return s.count } - const renderedItems = [] + const renderedItems: number[] = [] const Comp = () => { const value = useSelector(selector) @@ -387,7 +397,7 @@ describe('React', () => { numCalls += 1 return s.count } - const renderedItems = [] + const renderedItems: number[] = [] const Child = () => { useLayoutEffect(() => { @@ -746,13 +756,13 @@ describe('React', () => { null as any ) const useCustomSelector = createSelectorHook(nestedContext) - let defaultCount = null - let customCount = null + let defaultCount: number | null = null + let customCount: number | null = null const getCount = (s: StateType) => s.count const DisplayDefaultCount = ({ children = null }) => { - const count = useSelector(getCount) + const count = useSelector(getCount) defaultCount = count return <>{children} } @@ -760,7 +770,7 @@ describe('React', () => { children: ReactNode } const DisplayCustomCount = ({ children }: DisplayCustomCountType) => { - const count = useCustomSelector(getCount) + const count = useCustomSelector(getCount) customCount = count return <>{children} } From 80b8a51129b98b79eaa007c6df640c3337d44b0d Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Mon, 15 May 2023 22:57:44 +0100 Subject: [PATCH 3/8] add stability check tests --- test/hooks/useSelector.spec.tsx | 184 ++++++++++++++++++++++++++++---- 1 file changed, 164 insertions(+), 20 deletions(-) diff --git a/test/hooks/useSelector.spec.tsx b/test/hooks/useSelector.spec.tsx index bc08f93a3..734e03b60 100644 --- a/test/hooks/useSelector.spec.tsx +++ b/test/hooks/useSelector.spec.tsx @@ -26,6 +26,7 @@ import type { } from '../../src/index' import type { FunctionComponent, DispatchWithoutAction, ReactNode } from 'react' import type { Store, AnyAction } from 'redux' +import { StabilityCheck, UseSelectorOptions } from '../../src/hooks/useSelector' // most of these tests depend on selectors being run once, which stabilityCheck doesn't do // rather than specify it every time, let's make a new "default" here @@ -82,10 +83,7 @@ describe('React', () => { }) it('selects the state and renders the component when the store updates', () => { - type MockParams = [NormalStateType] - const selector: jest.Mock = jest.fn( - (s) => s.count - ) + const selector = jest.fn((s: NormalStateType) => s.count) let result: number | undefined const Comp = () => { const count = useNormalSelector(selector) @@ -324,9 +322,18 @@ describe('React', () => { ) const Comp = () => { - const value = useSelector((s) => { - return Object.keys(s) - }, shallowEqual) + const value = useSelector( + (s: StateType) => Object.keys(s), + shallowEqual + ) + renderedItems.push(value) + return
+ } + + const Comp2 = () => { + const value = useSelector((s: StateType) => Object.keys(s), { + equalityFn: shallowEqual, + }) renderedItems.push(value) return
} @@ -334,16 +341,17 @@ describe('React', () => { rtl.render( + ) - expect(renderedItems.length).toBe(1) + expect(renderedItems.length).toBe(2) rtl.act(() => { store.dispatch({ type: '' }) }) - expect(renderedItems.length).toBe(1) + expect(renderedItems.length).toBe(2) }) it('calls selector exactly once on mount and on update', () => { @@ -354,11 +362,9 @@ describe('React', () => { count: count + 1, })) - let numCalls = 0 - const selector = (s: StateType) => { - numCalls += 1 + const selector = jest.fn((s: StateType) => { return s.count - } + }) const renderedItems: number[] = [] const Comp = () => { @@ -373,14 +379,14 @@ describe('React', () => { ) - expect(numCalls).toBe(1) + expect(selector).toHaveBeenCalledTimes(1) expect(renderedItems.length).toEqual(1) rtl.act(() => { store.dispatch({ type: '' }) }) - expect(numCalls).toBe(2) + expect(selector).toHaveBeenCalledTimes(2) expect(renderedItems.length).toEqual(2) }) @@ -392,11 +398,9 @@ describe('React', () => { count: count + 1, })) - let numCalls = 0 - const selector = (s: StateType) => { - numCalls += 1 + const selector = jest.fn((s: StateType) => { return s.count - } + }) const renderedItems: number[] = [] const Child = () => { @@ -427,7 +431,7 @@ describe('React', () => { ) // Selector first called on Comp mount, and then re-invoked after mount due to useLayoutEffect dispatching event - expect(numCalls).toBe(2) + expect(selector).toHaveBeenCalledTimes(2) expect(renderedItems.length).toEqual(2) }) }) @@ -733,6 +737,146 @@ describe('React', () => { ).toThrow() }) }) + + describe('Development mode checks', () => { + describe('selector result stability check', () => { + const selector = jest.fn((state: NormalStateType) => state.count) + + const consoleSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}) + afterEach(() => { + consoleSpy.mockClear() + selector.mockClear() + }) + afterAll(() => { + consoleSpy.mockRestore() + }) + + const RenderSelector = ({ + selector, + options, + }: { + selector: (state: NormalStateType) => number + options?: UseSelectorOptions + }) => { + useSelector(selector, options) + return null + } + + it('calls a selector twice, and warns in console if it returns a different result', () => { + rtl.render( + + + + ) + + expect(selector).toHaveBeenCalledTimes(2) + + expect(consoleSpy).not.toHaveBeenCalled() + + rtl.cleanup() + + const unstableSelector = jest.fn(() => Math.random()) + + rtl.render( + + + + ) + + expect(selector).toHaveBeenCalledTimes(2) + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'returned a different result when called with the same parameters' + ), + expect.objectContaining({ + state: expect.objectContaining({ + count: 0, + }), + selected: expect.any(Number), + selected2: expect.any(Number), + }) + ) + }) + it('by default will only check on first selector call', () => { + rtl.render( + + + + ) + + expect(selector).toHaveBeenCalledTimes(2) + + rtl.act(() => { + normalStore.dispatch({ type: '' }) + }) + + expect(selector).toHaveBeenCalledTimes(3) + }) + it('disables check if context or hook specifies', () => { + rtl.render( + + + + ) + + expect(selector).toHaveBeenCalledTimes(1) + + rtl.cleanup() + + selector.mockClear() + + rtl.render( + + + + ) + + expect(selector).toHaveBeenCalledTimes(1) + }) + it('always runs check if context or hook specifies', () => { + rtl.render( + + + + ) + + expect(selector).toHaveBeenCalledTimes(2) + + rtl.act(() => { + normalStore.dispatch({ type: '' }) + }) + + expect(selector).toHaveBeenCalledTimes(4) + + rtl.cleanup() + + selector.mockClear() + + rtl.render( + + + + ) + + expect(selector).toHaveBeenCalledTimes(2) + + rtl.act(() => { + normalStore.dispatch({ type: '' }) + }) + + expect(selector).toHaveBeenCalledTimes(4) + }) + }) + }) }) describe('createSelectorHook', () => { From e0da9c1f64bd2fdea79e44b3378f2ddb7bc6593f Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Mon, 15 May 2023 23:37:10 +0100 Subject: [PATCH 4/8] Add docs --- docs/api/Provider.md | 3 +++ docs/api/hooks.md | 45 +++++++++++++++++++++++++++++++++++++ src/components/Provider.tsx | 2 ++ 3 files changed, 50 insertions(+) diff --git a/docs/api/Provider.md b/docs/api/Provider.md index 3c9ef6115..1ee84d727 100644 --- a/docs/api/Provider.md +++ b/docs/api/Provider.md @@ -43,6 +43,9 @@ interface ProviderProps { */ context?: Context> + /** Global configuration for the `useSelector` stability check */ + stabilityCheck?: StabilityCheck + /** The top-level React elements in your component tree, such as `` **/ children: ReactNode } diff --git a/docs/api/hooks.md b/docs/api/hooks.md index 337354ac2..decf64a4a 100644 --- a/docs/api/hooks.md +++ b/docs/api/hooks.md @@ -98,6 +98,11 @@ import { shallowEqual, useSelector } from 'react-redux' // later const selectedData = useSelector(selectorReturningObject, shallowEqual) + +// or with object format +const selectedData = useSelector(selectorReturningObject, { + equalityFn: shallowEqual, +}) ``` - Use a custom equality function as the `equalityFn` argument to `useSelector()`, like: @@ -240,6 +245,46 @@ export const App = () => { } ``` +### Development mode checks + +#### Selector result stability + +In development, an extra check is conducted on the passed selector. It runs the selector an extra time with the same parameter, and warns in console if it returns a different result (based on the `equalityFn` provided). + +This is important, as a selector returning a materially different result with the same parameter will cause unnecessary rerenders. + +```ts +// this selector will return a new object reference whenever called +// meaning the component will rerender whenever *any* action is dispatched +const { count, user } = useSelector((state) => ({ + count: state.count, + user: state.user, +})) +``` + +If a selector result is suitably stable, or memoised, it will not return a different result and thus not cause a warning to be logged. + +By default, this will only happen when the selector is first called. You can configure the check via context, or per `useSelector` call - either to run the check always, or never. + +```tsx title="Global setting via context" + + {children} + +``` + +```tsx title="Individual hook setting" +function Component() { + const count = useSelector(selectCount, { stabilityCheck: 'never' }) + // run once (default) + const user = useSelector(selectUser, { stabilityCheck: 'once' }) + // ... +} +``` + +:::info +This check is disabled for production environments. +::: + ## `useDispatch()` ```js diff --git a/src/components/Provider.tsx b/src/components/Provider.tsx index 09637f1f4..55cb71b80 100644 --- a/src/components/Provider.tsx +++ b/src/components/Provider.tsx @@ -23,7 +23,9 @@ export interface ProviderProps { */ context?: Context> + /** Global configuration for the `useSelector` stability check */ stabilityCheck?: StabilityCheck + children: ReactNode } From ca1be6770242b9579be38df6eaea43eccaa61af1 Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Mon, 15 May 2023 23:50:50 +0100 Subject: [PATCH 5/8] add equalityFn test --- test/hooks/useSelector.spec.tsx | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/test/hooks/useSelector.spec.tsx b/test/hooks/useSelector.spec.tsx index 734e03b60..20d1e5464 100644 --- a/test/hooks/useSelector.spec.tsx +++ b/test/hooks/useSelector.spec.tsx @@ -26,7 +26,7 @@ import type { } from '../../src/index' import type { FunctionComponent, DispatchWithoutAction, ReactNode } from 'react' import type { Store, AnyAction } from 'redux' -import { StabilityCheck, UseSelectorOptions } from '../../src/hooks/useSelector' +import { UseSelectorOptions } from '../../src/hooks/useSelector' // most of these tests depend on selectors being run once, which stabilityCheck doesn't do // rather than specify it every time, let's make a new "default" here @@ -757,8 +757,8 @@ describe('React', () => { selector, options, }: { - selector: (state: NormalStateType) => number - options?: UseSelectorOptions + selector: (state: NormalStateType) => unknown + options?: UseSelectorOptions }) => { useSelector(selector, options) return null @@ -800,6 +800,23 @@ describe('React', () => { }) ) }) + it('uses provided equalityFn', () => { + const unstableSelector = jest.fn((state: NormalStateType) => ({ + count: state.count, + })) + + rtl.render( + + + + ) + + expect(unstableSelector).toHaveBeenCalledTimes(2) + expect(consoleSpy).not.toHaveBeenCalled() + }) it('by default will only check on first selector call', () => { rtl.render( From 7039625d460cc2ae0ad687c422dc824a55878895 Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Sat, 3 Jun 2023 00:08:55 +0100 Subject: [PATCH 6/8] rm extra space --- src/hooks/useSelector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useSelector.ts b/src/hooks/useSelector.ts index eb0b5b797..f20ff17fb 100644 --- a/src/hooks/useSelector.ts +++ b/src/hooks/useSelector.ts @@ -99,7 +99,7 @@ export function createSelectorHook(context = ReactReduxContext): UseSelector { 'Selector ' + (selector.name || 'unknown') + ' returned a different result when called with the same parameters. This can lead to unnecessary rerenders.' + - '\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', + '\nSelectors 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', { state, selected, From 9a617b38de833bcc73e89b74ffc52598571fa40b Mon Sep 17 00:00:00 2001 From: Ben Durrant Date: Sun, 4 Jun 2023 00:10:58 +0100 Subject: [PATCH 7/8] Copy over wording changes --- docs/api/hooks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/hooks.md b/docs/api/hooks.md index decf64a4a..db2963ec6 100644 --- a/docs/api/hooks.md +++ b/docs/api/hooks.md @@ -262,9 +262,9 @@ const { count, user } = useSelector((state) => ({ })) ``` -If a selector result is suitably stable, or memoised, it will not return a different result and thus not cause a warning to be logged. +If a selector result is suitably stable (or the selector is memoized), it will not return a different result and no warning will be logged. -By default, this will only happen when the selector is first called. You can configure the check via context, or per `useSelector` call - either to run the check always, or never. +By default, this will only happen when the selector is first called. You can configure the check in the Provider or at each `useSelector` call. ```tsx title="Global setting via context" From b6cbd2dad63bd1b2c1c9656de649a02f148561b1 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 5 Jun 2023 23:28:49 -0400 Subject: [PATCH 8/8] Update hooks docs with TS types and better descriptions --- docs/api/hooks.md | 89 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/docs/api/hooks.md b/docs/api/hooks.md index db2963ec6..b81341d72 100644 --- a/docs/api/hooks.md +++ b/docs/api/hooks.md @@ -44,27 +44,42 @@ From there, you may import any of the listed React Redux hooks APIs and use them ## `useSelector()` -```js -const result: any = useSelector(selector: Function, equalityFn?: Function) +```ts +type RootState = ReturnType +type SelectorFn = (state: RootState) => Selected +type EqualityFn = (a: any, b: any) => boolean +export type StabilityCheck = 'never' | 'once' | 'always' + +interface UseSelectorOptions { + equalityFn?: EqualityFn + stabilityCheck?: StabilityCheck +} + +const result: Selected = useSelector( + selector: SelectorFunction, + options?: EqualityFn | UseSelectorOptions +) ``` -Allows you to extract data from the Redux store state, using a selector function. +Allows you to extract data from the Redux store state for use in this component, using a selector function. :::info The selector function should be [pure](https://en.wikipedia.org/wiki/Pure_function) since it is potentially executed multiple times and at arbitrary points in time. +See [Using Redux: Deriving Data with Selectors](https://redux.js.org/usage/deriving-data-selectors) in the Redux docs for more details on writing and using selector functions. + ::: -The selector is approximately equivalent to the [`mapStateToProps` argument to `connect`](../using-react-redux/connect-extracting-data-with-mapStateToProps.md) conceptually. The selector will be called with the entire Redux store state as its only argument. The selector will be run whenever the function component renders (unless its reference hasn't changed since a previous render of the component so that a cached result can be returned by the hook without re-running the selector). `useSelector()` will also subscribe to the Redux store, and run your selector whenever an action is dispatched. +The selector will be called with the entire Redux store state as its only argument. The selector may return any value as a result, including directly returning a value that was nested inside `state`, or deriving new values. The return value of the selector will be used as the return value of the `useSelector()` hook. + +The selector will be run whenever the function component renders (unless its reference hasn't changed since a previous render of the component so that a cached result can be returned by the hook without re-running the selector). `useSelector()` will also subscribe to the Redux store, and run your selector whenever an action is dispatched. -However, there are some differences between the selectors passed to `useSelector()` and a `mapState` function: +When an action is dispatched, `useSelector()` will do a reference comparison of the previous selector result value and the current result value. If they are different, the component will be forced to re-render. If they are the same, the component will not re-render. `useSelector()` uses strict `===` reference equality checks by default, not shallow equality (see the following section for more details). -- The selector may return any value as a result, not just an object. The return value of the selector will be used as the return value of the `useSelector()` hook. -- When an action is dispatched, `useSelector()` will do a reference comparison of the previous selector result value and the current result value. If they are different, the component will be forced to re-render. If they are the same, the component will not re-render. -- The selector function does _not_ receive an `ownProps` argument. However, props can be used through closure (see the examples below) or by using a curried selector. -- Extra care must be taken when using memoizing selectors (see examples below for more details). -- `useSelector()` uses strict `===` reference equality checks by default, not shallow equality (see the following section for more details). +The selector is approximately equivalent to the [`mapStateToProps` argument to `connect`](../using-react-redux/connect-extracting-data-with-mapStateToProps.md) conceptually. + +You may call `useSelector()` multiple times within a single function component. Each call to `useSelector()` creates an individual subscription to the Redux store. Because of the React update batching behavior used in React Redux v7, a dispatched action that causes multiple `useSelector()`s in the same component to return new values _should_ only result in a single re-render. :::info @@ -72,8 +87,6 @@ There are potential edge cases with using props in selectors that may cause issu ::: -You may call `useSelector()` multiple times within a single function component. Each call to `useSelector()` creates an individual subscription to the Redux store. Because of the React update batching behavior used in React Redux v7, a dispatched action that causes multiple `useSelector()`s in the same component to return new values _should_ only result in a single re-render. - ### Equality Comparisons and Updates When the function component renders, the provided selector function will be called and its result will be returned @@ -96,10 +109,10 @@ every time will _always_ force a re-render by default. If you want to retrieve m ```js import { shallowEqual, useSelector } from 'react-redux' -// later +// Pass it as the second argument directly const selectedData = useSelector(selectorReturningObject, shallowEqual) -// or with object format +// or pass it as the `equalityFn` field in the options argument const selectedData = useSelector(selectorReturningObject, { equalityFn: shallowEqual, }) @@ -247,15 +260,23 @@ export const App = () => { ### Development mode checks +`useSelector` runs some extra checks in development mode to watch for unexpected behavior. These checks do not run in production builds. + +:::info + +These checks were first added in v8.1.0 + +::: + #### Selector result stability -In development, an extra check is conducted on the passed selector. It runs the selector an extra time with the same parameter, and warns in console if it returns a different result (based on the `equalityFn` provided). +In development, the provided selector function is run an extra time with the same parameter during the first call to `useSelector`, and warns in the console if the selector returns a different result (based on the `equalityFn` provided). -This is important, as a selector returning a materially different result with the same parameter will cause unnecessary rerenders. +This is important, as a selector returning that returns a different result reference with the same parameter will cause unnecessary rerenders. ```ts -// this selector will return a new object reference whenever called -// meaning the component will rerender whenever *any* action is dispatched +// this selector will return a new object reference whenever called, +// which causes the component to rerender after *every* action is dispatched const { count, user } = useSelector((state) => ({ count: state.count, user: state.user, @@ -281,14 +302,20 @@ function Component() { } ``` -:::info -This check is disabled for production environments. -::: +### Comparisons with `connect` + +There are some differences between the selectors passed to `useSelector()` and a `mapState` function: + +- The selector may return any value as a result, not just an object. +- The selector normally _should_ return just a single value, and not an object. If you do return an object or an array, be sure to use a memoized selector to avoid unnecessary re-renders. +- The selector function does _not_ receive an `ownProps` argument. However, props can be used through closure (see the examples above) or by using a curried selector. +- You can use the `equalityFn` option to customize the comparison behavior ## `useDispatch()` -```js -const dispatch = useDispatch() +```ts +import type { Dispatch } from 'redux' +const dispatch: Dispatch = useDispatch() ``` This hook returns a reference to the `dispatch` function from the Redux store. You may use it to dispatch actions as needed. @@ -364,8 +391,9 @@ export const Todos = () => { ## `useStore()` -```js -const store = useStore() +```ts +import type { Store } from 'redux' +const store: Store = useStore() ``` This hook returns a reference to the same Redux store that was passed in to the `` component. @@ -378,12 +406,19 @@ This hook should probably not be used frequently. Prefer `useSelector()` as your import React from 'react' import { useStore } from 'react-redux' -export const CounterComponent = ({ value }) => { +export const ExampleComponent = ({ value }) => { const store = useStore() + const onClick = () => { + // Not _recommended_, but safe + // This avoids subscribing to the state via `useSelector` + // Prefer moving this logic into a thunk instead + const numTodos = store.getState().todos.length + } + // EXAMPLE ONLY! Do not do this in a real app. // The component will not automatically update if the store state changes - return
{store.getState()}
+ return
{store.getState().todos.length}
} ```