diff --git a/docs/tutorials/typescript.md b/docs/tutorials/typescript.md index 9cf79a26b..6f52c5df8 100644 --- a/docs/tutorials/typescript.md +++ b/docs/tutorials/typescript.md @@ -79,13 +79,13 @@ While it's possible to import the `RootState` and `AppDispatch` types into each Since these are actual variables, not types, it's important to define them in a separate file such as `app/hooks.ts`, not the store setup file. This allows you to import them into any component file that needs to use the hooks, and avoids potential circular import dependency issues. ```ts title="app/hooks.ts" -import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import type { RootState, AppDispatch } from './store' // highlight-start // Use throughout your app instead of plain `useDispatch` and `useSelector` -export const useAppDispatch: () => AppDispatch = useDispatch -export const useAppSelector: TypedUseSelectorHook = useSelector +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector = useSelector.withTypes() // highlight-end ``` diff --git a/docs/using-react-redux/usage-with-typescript.md b/docs/using-react-redux/usage-with-typescript.md index ebfc22ec4..8d4a45d5e 100644 --- a/docs/using-react-redux/usage-with-typescript.md +++ b/docs/using-react-redux/usage-with-typescript.md @@ -64,13 +64,13 @@ While it's possible to import the `RootState` and `AppDispatch` types into each Since these are actual variables, not types, it's important to define them in a separate file such as `app/hooks.ts`, not the store setup file. This allows you to import them into any component file that needs to use the hooks, and avoids potential circular import dependency issues. ```ts title="app/hooks.ts" -import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import type { RootState, AppDispatch } from './store' // highlight-start // Use throughout your app instead of plain `useDispatch` and `useSelector` -export const useAppDispatch: () => AppDispatch = useDispatch -export const useAppSelector: TypedUseSelectorHook = useSelector +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector = useSelector.withTypes() // highlight-end ``` diff --git a/src/exports.ts b/src/exports.ts index 556f93b33..f826f2d11 100644 --- a/src/exports.ts +++ b/src/exports.ts @@ -1,55 +1,46 @@ -import Provider from './components/Provider' -import type { ProviderProps } from './components/Provider' import connect from './components/connect' -import type { +export type { Connect, ConnectProps, ConnectedProps, } from './components/connect' -import type { - SelectorFactory, - Selector, - MapStateToProps, - MapStateToPropsFactory, - MapStateToPropsParam, - MapDispatchToPropsFunction, - MapDispatchToProps, - MapDispatchToPropsFactory, - MapDispatchToPropsParam, - MapDispatchToPropsNonObject, - MergeProps, -} from './connect/selectorFactory' -import { ReactReduxContext } from './components/Context' -import type { ReactReduxContextValue } from './components/Context' - -import { useDispatch, createDispatchHook } from './hooks/useDispatch' -import { useSelector, createSelectorHook } from './hooks/useSelector' -import { useStore, createStoreHook } from './hooks/useStore' import shallowEqual from './utils/shallowEqual' -import type { Subscription } from './utils/Subscription' + +import Provider from './components/Provider' import { defaultNoopBatch } from './utils/batch' -export * from './types' +export { ReactReduxContext } from './components/Context' +export type { ReactReduxContextValue } from './components/Context' + +export type { ProviderProps } from './components/Provider' + export type { - ProviderProps, - SelectorFactory, - Selector, - MapStateToProps, - MapStateToPropsFactory, - MapStateToPropsParam, - Connect, - ConnectProps, - ConnectedProps, - MapDispatchToPropsFunction, MapDispatchToProps, MapDispatchToPropsFactory, - MapDispatchToPropsParam, + MapDispatchToPropsFunction, MapDispatchToPropsNonObject, + MapDispatchToPropsParam, + MapStateToProps, + MapStateToPropsFactory, + MapStateToPropsParam, MergeProps, - ReactReduxContextValue, - Subscription, -} + Selector, + SelectorFactory, +} from './connect/selectorFactory' + +export { createDispatchHook, useDispatch } from './hooks/useDispatch' +export type { UseDispatch } from './hooks/useDispatch' + +export { createSelectorHook, useSelector } from './hooks/useSelector' +export type { UseSelector } from './hooks/useSelector' + +export { createStoreHook, useStore } from './hooks/useStore' +export type { UseStore } from './hooks/useStore' + +export type { Subscription } from './utils/Subscription' + +export * from './types' /** * @deprecated As of React 18, batching is enabled by default for ReactDOM and React Native. @@ -57,16 +48,4 @@ export type { */ const batch = defaultNoopBatch -export { - Provider, - ReactReduxContext, - connect, - useDispatch, - createDispatchHook, - useSelector, - createSelectorHook, - useStore, - createStoreHook, - shallowEqual, - batch, -} +export { Provider, batch, connect, shallowEqual } diff --git a/src/hooks/useDispatch.ts b/src/hooks/useDispatch.ts index c5520dc43..011c5c7e8 100644 --- a/src/hooks/useDispatch.ts +++ b/src/hooks/useDispatch.ts @@ -1,9 +1,53 @@ -import type { Action, Dispatch, UnknownAction } from 'redux' import type { Context } from 'react' +import type { Action, Dispatch, UnknownAction } from 'redux' import type { ReactReduxContextValue } from '../components/Context' import { ReactReduxContext } from '../components/Context' -import { useStore as useDefaultStore, createStoreHook } from './useStore' +import { createStoreHook, useStore as useDefaultStore } from './useStore' + +/** + * Represents a custom hook that provides a dispatch function + * from the Redux store. + * + * @template DispatchType - The specific type of the dispatch function. + * + * @since 9.1.0 + * @public + */ +export interface UseDispatch< + DispatchType extends Dispatch = Dispatch +> { + /** + * Returns the dispatch function from the Redux store. + * + * @returns The dispatch function from the Redux store. + * + * @template AppDispatch - The specific type of the dispatch function. + */ + (): AppDispatch + + /** + * Creates a "pre-typed" version of {@linkcode useDispatch useDispatch} + * where the type of the `dispatch` function is predefined. + * + * This allows you to set the `dispatch` type once, eliminating the need to + * specify it with every {@linkcode useDispatch useDispatch} call. + * + * @returns A pre-typed `useDispatch` with the dispatch type already defined. + * + * @example + * ```ts + * export const useAppDispatch = useDispatch.withTypes() + * ``` + * + * @template OverrideDispatchType - The specific type of the dispatch function. + * + * @since 9.1.0 + */ + withTypes: < + OverrideDispatchType extends DispatchType + >() => UseDispatch +} /** * Hook factory, which creates a `useDispatch` hook bound to a given context. @@ -12,21 +56,28 @@ import { useStore as useDefaultStore, createStoreHook } from './useStore' * @returns {Function} A `useDispatch` hook bound to the specified context. */ export function createDispatchHook< - S = unknown, - A extends Action = UnknownAction + StateType = unknown, + ActionType extends Action = UnknownAction +>( // @ts-ignore ->(context?: Context | null> = ReactReduxContext) { + context?: Context | null> = ReactReduxContext +) { const useStore = - // @ts-ignore context === ReactReduxContext ? useDefaultStore : createStoreHook(context) - return function useDispatch< - AppDispatch extends Dispatch = Dispatch - >(): AppDispatch { + const useDispatch = () => { const store = useStore() - // @ts-ignore return store.dispatch } + + Object.assign(useDispatch, { + withTypes: () => useDispatch, + }) + + return useDispatch as UseDispatch> } /** diff --git a/src/hooks/useSelector.ts b/src/hooks/useSelector.ts index 544ad84c6..3878a8862 100644 --- a/src/hooks/useSelector.ts +++ b/src/hooks/useSelector.ts @@ -66,15 +66,56 @@ export interface UseSelectorOptions { devModeChecks?: Partial } -export interface UseSelector { - ( - selector: (state: TState) => Selected, - equalityFn?: EqualityFn, - ): Selected - ( +/** + * Represents a custom hook that allows you to extract data from the + * Redux store state, using a selector function. The selector function + * takes the current state as an argument and returns a part of the state + * or some derived data. The hook also supports an optional equality + * function or options object to customize its behavior. + * + * @template StateType - The specific type of state this hook operates on. + * + * @public + */ +export interface UseSelector { + /** + * A function that takes a selector function as its first argument. + * The selector function is responsible for selecting a part of + * the Redux store's state or computing derived data. + * + * @param selector - A function that receives the current state and returns a part of the state or some derived data. + * @param equalityFnOrOptions - An optional equality function or options object for customizing the behavior of the selector. + * @returns The selected part of the state or derived data. + * + * @template TState - The specific type of state this hook operates on. + * @template Selected - The type of the value that the selector function will return. + */ + ( selector: (state: TState) => Selected, - options?: UseSelectorOptions, + equalityFnOrOptions?: EqualityFn | UseSelectorOptions ): Selected + + /** + * Creates a "pre-typed" version of {@linkcode useSelector useSelector} + * where the `state` type is predefined. + * + * This allows you to set the `state` type once, eliminating the need to + * specify it with every {@linkcode useSelector useSelector} call. + * + * @returns A pre-typed `useSelector` with the state type already defined. + * + * @example + * ```ts + * export const useAppSelector = useSelector.withTypes() + * ``` + * + * @template OverrideStateType - The specific type of state this hook operates on. + * + * @since 9.1.0 + */ + withTypes: < + OverrideStateType extends StateType + >() => UseSelector } let useSyncExternalStoreWithSelector = notInitialized as uSESWS @@ -101,12 +142,12 @@ export function createSelectorHook( ? useDefaultReduxContext : createReduxContextHook(context) - return function useSelector( + const useSelector = ( selector: (state: TState) => Selected, equalityFnOrOptions: | EqualityFn> - | UseSelectorOptions> = {}, - ): Selected { + | UseSelectorOptions> = {} + ): Selected => { const { equalityFn = refEquality, devModeChecks = {} } = typeof equalityFnOrOptions === 'function' ? { equalityFn: equalityFnOrOptions } @@ -217,6 +258,12 @@ export function createSelectorHook( return selectedState } + + Object.assign(useSelector, { + withTypes: () => useSelector, + }) + + return useSelector as UseSelector } /** diff --git a/src/hooks/useStore.ts b/src/hooks/useStore.ts index cc529d315..40a0d31e7 100644 --- a/src/hooks/useStore.ts +++ b/src/hooks/useStore.ts @@ -1,12 +1,77 @@ import type { Context } from 'react' -import type { Action as BasicAction, UnknownAction, Store } from 'redux' +import type { Action, Store } from 'redux' import type { ReactReduxContextValue } from '../components/Context' import { ReactReduxContext } from '../components/Context' import { - useReduxContext as useDefaultReduxContext, createReduxContextHook, + useReduxContext as useDefaultReduxContext, } from './useReduxContext' +/** + * Represents a type that extracts the action type from a given Redux store. + * + * @template StoreType - The specific type of the Redux store. + * + * @since 9.1.0 + * @internal + */ +export type ExtractStoreActionType = + StoreType extends Store ? ActionType : never + +/** + * Represents a custom hook that provides access to the Redux store. + * + * @template StoreType - The specific type of the Redux store that gets returned. + * + * @since 9.1.0 + * @public + */ +export interface UseStore { + /** + * Returns the Redux store instance. + * + * @returns The Redux store instance. + */ + (): StoreType + + /** + * Returns the Redux store instance with specific state and action types. + * + * @returns The Redux store with the specified state and action types. + * + * @template StateType - The specific type of the state used in the store. + * @template ActionType - The specific type of the actions used in the store. + */ + < + StateType extends ReturnType = ReturnType< + StoreType['getState'] + >, + ActionType extends Action = ExtractStoreActionType + >(): Store + + /** + * Creates a "pre-typed" version of {@linkcode useStore useStore} + * where the type of the Redux `store` is predefined. + * + * This allows you to set the `store` type once, eliminating the need to + * specify it with every {@linkcode useStore useStore} call. + * + * @returns A pre-typed `useStore` with the store type already defined. + * + * @example + * ```ts + * export const useAppStore = useStore.withTypes() + * ``` + * + * @template OverrideStoreType - The specific type of the Redux store that gets returned. + * + * @since 9.1.0 + */ + withTypes: < + OverrideStoreType extends StoreType + >() => UseStore +} + /** * Hook factory, which creates a `useStore` hook bound to a given context. * @@ -14,25 +79,30 @@ import { * @returns {Function} A `useStore` hook bound to the specified context. */ export function createStoreHook< - S = unknown, - A extends BasicAction = UnknownAction + StateType = unknown, + ActionType extends Action = Action +>( // @ts-ignore ->(context?: Context | null> = ReactReduxContext) { + context?: Context | null> = ReactReduxContext +) { const useReduxContext = - // @ts-ignore context === ReactReduxContext ? useDefaultReduxContext : // @ts-ignore createReduxContextHook(context) - return function useStore< - State = S, - Action2 extends BasicAction = A - // @ts-ignore - >() { + const useStore = () => { const { store } = useReduxContext() - // @ts-ignore - return store as Store + return store } + + Object.assign(useStore, { + withTypes: () => useStore, + }) + + return useStore as UseStore> } /** diff --git a/test/hooks/hooks.withTypes.test.tsx b/test/hooks/hooks.withTypes.test.tsx new file mode 100644 index 000000000..2a5823c56 --- /dev/null +++ b/test/hooks/hooks.withTypes.test.tsx @@ -0,0 +1,76 @@ +import type { Action, ThunkAction } from '@reduxjs/toolkit' +import { configureStore, createAsyncThunk, createSlice } from '@reduxjs/toolkit' +import { useDispatch, useSelector, useStore } from '../../src' + +export interface CounterState { + counter: number +} + +const initialState: CounterState = { + counter: 0, +} + +export const counterSlice = createSlice({ + name: 'counter', + initialState, + reducers: { + increment(state) { + state.counter++ + }, + }, +}) + +export function fetchCount(amount = 1) { + return new Promise<{ data: number }>((resolve) => + setTimeout(() => resolve({ data: amount }), 500) + ) +} + +export const incrementAsync = createAsyncThunk( + 'counter/fetchCount', + async (amount: number) => { + const response = await fetchCount(amount) + // The value we return becomes the `fulfilled` action payload + return response.data + } +) + +const { increment } = counterSlice.actions + +const counterStore = configureStore({ + reducer: counterSlice.reducer, +}) + +type AppStore = typeof counterStore +type AppDispatch = typeof counterStore.dispatch +type RootState = ReturnType +type AppThunk = ThunkAction< + ThunkReturnType, + RootState, + unknown, + Action +> + +describe('useSelector.withTypes()', () => { + test('should return useSelector', () => { + const useAppSelector = useSelector.withTypes() + + expect(useAppSelector).toBe(useSelector) + }) +}) + +describe('useDispatch.withTypes()', () => { + test('should return useDispatch', () => { + const useAppDispatch = useDispatch.withTypes() + + expect(useAppDispatch).toBe(useDispatch) + }) +}) + +describe('useStore.withTypes()', () => { + test('should return useStore', () => { + const useAppStore = useStore.withTypes() + + expect(useAppStore).toBe(useStore) + }) +}) diff --git a/test/typeTestHelpers.ts b/test/typeTestHelpers.ts index f31b16d99..0bd07ab99 100644 --- a/test/typeTestHelpers.ts +++ b/test/typeTestHelpers.ts @@ -27,6 +27,25 @@ type Equals = IsAny< never, IsAny > + +export type IsEqual = (() => G extends A ? 1 : 2) extends < + G +>() => G extends B ? 1 : 2 + ? true + : false + +export type IfEquals< + T, + U, + TypeIfEquals = unknown, + TypeIfNotEquals = never +> = IsEqual extends true ? TypeIfEquals : TypeIfNotEquals + +export declare const exactType: ( + draft: T & IfEquals, + expected: U & IfEquals +) => IfEquals + export function expectExactType(t: T) { return >(u: U) => {} } diff --git a/test/typetests/counterApp.ts b/test/typetests/counterApp.ts index 21c7d55af..464d38b97 100644 --- a/test/typetests/counterApp.ts +++ b/test/typetests/counterApp.ts @@ -41,6 +41,7 @@ const counterStore = configureStore({ middleware: (gdm) => gdm(), }) +export type AppStore = typeof counterStore export type AppDispatch = typeof counterStore.dispatch export type RootState = ReturnType export type AppThunk = ThunkAction< diff --git a/test/typetests/hooks.tsx b/test/typetests/hooks.tsx index 99e00ce54..9779c26b6 100644 --- a/test/typetests/hooks.tsx +++ b/test/typetests/hooks.tsx @@ -84,7 +84,7 @@ function testShallowEqual() { ) expectExactType(selected1) - const useAppSelector: TypedUseSelectorHook = useSelector + const useAppSelector = useSelector.withTypes() const selected2 = useAppSelector((state) => state.stateProp, shallowEqual) expectExactType(selected2) diff --git a/test/typetests/hooks.withTypes.test-d.tsx b/test/typetests/hooks.withTypes.test-d.tsx new file mode 100644 index 000000000..ed698f580 --- /dev/null +++ b/test/typetests/hooks.withTypes.test-d.tsx @@ -0,0 +1,45 @@ +import * as React from 'react' +import { useDispatch, useSelector, useStore } from '../../src/index' +import { exactType, expectExactType } from '../typeTestHelpers' +import type { AppDispatch, AppStore, RootState } from './counterApp' +import { incrementAsync } from './counterApp' + +function preTypedHooksSetupWithTypes() { + const useAppSelector = useSelector.withTypes() + + const useAppDispatch = useDispatch.withTypes() + + const useAppStore = useStore.withTypes() + + function CounterComponent() { + useAppSelector((state) => state.counter) + + const dispatch = useAppDispatch() + + expectExactType(dispatch) + + const store = useAppStore() + + expectExactType(store) + + expectExactType(store.dispatch) + + const state = store.getState() + + expectExactType(state) + + expectExactType(state.counter) + + store.dispatch(incrementAsync(1)) + + exactType(store.dispatch, dispatch) + + return ( +