UNPKG

@reduxjs/toolkit

Version:

The official, opinionated, batteries-included toolset for efficient Redux development

448 lines (410 loc) 14.9 kB
import type { Reducer, StateFromReducersMapObject, UnknownAction } from 'redux' import { combineReducers } from 'redux' import { nanoid } from './nanoid' import type { Id, NonUndefined, Tail, UnionToIntersection, WithOptionalProp, } from './tsHelpers' import { getOrInsertComputed } from './utils' type SliceLike<ReducerPath extends string, State> = { reducerPath: ReducerPath reducer: Reducer<State> } type AnySliceLike = SliceLike<string, any> type SliceLikeReducerPath<A extends AnySliceLike> = A extends SliceLike<infer ReducerPath, any> ? ReducerPath : never type SliceLikeState<A extends AnySliceLike> = A extends SliceLike<any, infer State> ? State : never export type WithSlice<A extends AnySliceLike> = { [Path in SliceLikeReducerPath<A>]: SliceLikeState<A> } type ReducerMap = Record<string, Reducer> type ExistingSliceLike<DeclaredState> = { [ReducerPath in keyof DeclaredState]: SliceLike< ReducerPath & string, NonUndefined<DeclaredState[ReducerPath]> > }[keyof DeclaredState] export type InjectConfig = { /** * Allow replacing reducer with a different reference. Normally, an error will be thrown if a different reducer instance to the one already injected is used. */ overrideExisting?: boolean } /** * A reducer that allows for slices/reducers to be injected after initialisation. */ export interface CombinedSliceReducer< InitialState, DeclaredState = InitialState, > extends Reducer<DeclaredState, UnknownAction, Partial<DeclaredState>> { /** * Provide a type for slices that will be injected lazily. * * One way to do this would be with interface merging: * ```ts * * export interface LazyLoadedSlices {} * * export const rootReducer = combineSlices(stringSlice).withLazyLoadedSlices<LazyLoadedSlices>(); * * // elsewhere * * declare module './reducer' { * export interface LazyLoadedSlices extends WithSlice<typeof booleanSlice> {} * } * * const withBoolean = rootReducer.inject(booleanSlice); * * // elsewhere again * * declare module './reducer' { * export interface LazyLoadedSlices { * customName: CustomState * } * } * * const withCustom = rootReducer.inject({ reducerPath: "customName", reducer: customSlice.reducer }) * ``` */ withLazyLoadedSlices<Lazy = {}>(): CombinedSliceReducer< InitialState, Id<DeclaredState & Partial<Lazy>> > /** * Inject a slice. * * Accepts an individual slice, RTKQ API instance, or a "slice-like" { reducerPath, reducer } object. * * ```ts * rootReducer.inject(booleanSlice) * rootReducer.inject(baseApi) * rootReducer.inject({ reducerPath: 'boolean' as const, reducer: newReducer }, { overrideExisting: true }) * ``` * */ inject<Sl extends Id<ExistingSliceLike<DeclaredState>>>( slice: Sl, config?: InjectConfig, ): CombinedSliceReducer<InitialState, Id<DeclaredState & WithSlice<Sl>>> /** * Inject a slice. * * Accepts an individual slice, RTKQ API instance, or a "slice-like" { reducerPath, reducer } object. * * ```ts * rootReducer.inject(booleanSlice) * rootReducer.inject(baseApi) * rootReducer.inject({ reducerPath: 'boolean' as const, reducer: newReducer }, { overrideExisting: true }) * ``` * */ inject<ReducerPath extends string, State>( slice: SliceLike< ReducerPath, State & (ReducerPath extends keyof DeclaredState ? never : State) >, config?: InjectConfig, ): CombinedSliceReducer< InitialState, Id<DeclaredState & WithSlice<SliceLike<ReducerPath, State>>> > /** * Create a selector that guarantees that the slices injected will have a defined value when selector is run. * * ```ts * const selectBooleanWithoutInjection = (state: RootState) => state.boolean; * // ^? boolean | undefined * * const selectBoolean = rootReducer.inject(booleanSlice).selector((state) => { * // if action hasn't been dispatched since slice was injected, this would usually be undefined * // however selector() uses a Proxy around the first parameter to ensure that it evaluates to the initial state instead, if undefined * return state.boolean; * // ^? boolean * }) * ``` * * If the reducer is nested inside the root state, a selectState callback can be passed to retrieve the reducer's state. * * ```ts * * export interface LazyLoadedSlices {}; * * export const innerReducer = combineSlices(stringSlice).withLazyLoadedSlices<LazyLoadedSlices>(); * * export const rootReducer = combineSlices({ inner: innerReducer }); * * export type RootState = ReturnType<typeof rootReducer>; * * // elsewhere * * declare module "./reducer.ts" { * export interface LazyLoadedSlices extends WithSlice<typeof booleanSlice> {} * } * * const withBool = innerReducer.inject(booleanSlice); * * const selectBoolean = withBool.selector( * (state) => state.boolean, * (rootState: RootState) => state.inner * ); * // now expects to be passed RootState instead of innerReducer state * * ``` * * Value passed to selectorFn will be a Proxy - use selector.original(proxy) to get original state value (useful for debugging) * * ```ts * const injectedReducer = rootReducer.inject(booleanSlice); * const selectBoolean = injectedReducer.selector((state) => { * console.log(injectedReducer.selector.original(state).boolean) // possibly undefined * return state.boolean * }) * ``` */ selector: { /** * Create a selector that guarantees that the slices injected will have a defined value when selector is run. * * ```ts * const selectBooleanWithoutInjection = (state: RootState) => state.boolean; * // ^? boolean | undefined * * const selectBoolean = rootReducer.inject(booleanSlice).selector((state) => { * // if action hasn't been dispatched since slice was injected, this would usually be undefined * // however selector() uses a Proxy around the first parameter to ensure that it evaluates to the initial state instead, if undefined * return state.boolean; * // ^? boolean * }) * ``` * * Value passed to selectorFn will be a Proxy - use selector.original(proxy) to get original state value (useful for debugging) * * ```ts * const injectedReducer = rootReducer.inject(booleanSlice); * const selectBoolean = injectedReducer.selector((state) => { * console.log(injectedReducer.selector.original(state).boolean) // undefined * return state.boolean * }) * ``` */ <Selector extends (state: DeclaredState, ...args: any[]) => unknown>( selectorFn: Selector, ): ( state: WithOptionalProp< Parameters<Selector>[0], Exclude<keyof DeclaredState, keyof InitialState> >, ...args: Tail<Parameters<Selector>> ) => ReturnType<Selector> /** * Create a selector that guarantees that the slices injected will have a defined value when selector is run. * * ```ts * const selectBooleanWithoutInjection = (state: RootState) => state.boolean; * // ^? boolean | undefined * * const selectBoolean = rootReducer.inject(booleanSlice).selector((state) => { * // if action hasn't been dispatched since slice was injected, this would usually be undefined * // however selector() uses a Proxy around the first parameter to ensure that it evaluates to the initial state instead, if undefined * return state.boolean; * // ^? boolean * }) * ``` * * If the reducer is nested inside the root state, a selectState callback can be passed to retrieve the reducer's state. * * ```ts * * interface LazyLoadedSlices {}; * * const innerReducer = combineSlices(stringSlice).withLazyLoadedSlices<LazyLoadedSlices>(); * * const rootReducer = combineSlices({ inner: innerReducer }); * * type RootState = ReturnType<typeof rootReducer>; * * // elsewhere * * declare module "./reducer.ts" { * interface LazyLoadedSlices extends WithSlice<typeof booleanSlice> {} * } * * const withBool = innerReducer.inject(booleanSlice); * * const selectBoolean = withBool.selector( * (state) => state.boolean, * (rootState: RootState) => state.inner * ); * // now expects to be passed RootState instead of innerReducer state * * ``` * * Value passed to selectorFn will be a Proxy - use selector.original(proxy) to get original state value (useful for debugging) * * ```ts * const injectedReducer = rootReducer.inject(booleanSlice); * const selectBoolean = injectedReducer.selector((state) => { * console.log(injectedReducer.selector.original(state).boolean) // possibly undefined * return state.boolean * }) * ``` */ < Selector extends (state: DeclaredState, ...args: any[]) => unknown, RootState, >( selectorFn: Selector, selectState: ( rootState: RootState, ...args: Tail<Parameters<Selector>> ) => WithOptionalProp< Parameters<Selector>[0], Exclude<keyof DeclaredState, keyof InitialState> >, ): ( state: RootState, ...args: Tail<Parameters<Selector>> ) => ReturnType<Selector> /** * Returns the unproxied state. Useful for debugging. * @param state state Proxy, that ensures injected reducers have value * @returns original, unproxied state * @throws if value passed is not a state Proxy */ original: (state: DeclaredState) => InitialState & Partial<DeclaredState> } } type InitialState<Slices extends Array<AnySliceLike | ReducerMap>> = UnionToIntersection< Slices[number] extends infer Slice ? Slice extends AnySliceLike ? WithSlice<Slice> : StateFromReducersMapObject<Slice> : never > const isSliceLike = ( maybeSliceLike: AnySliceLike | ReducerMap, ): maybeSliceLike is AnySliceLike => 'reducerPath' in maybeSliceLike && typeof maybeSliceLike.reducerPath === 'string' const getReducers = (slices: Array<AnySliceLike | ReducerMap>) => slices.flatMap((sliceOrMap) => isSliceLike(sliceOrMap) ? [[sliceOrMap.reducerPath, sliceOrMap.reducer] as const] : Object.entries(sliceOrMap), ) const ORIGINAL_STATE = Symbol.for('rtk-state-proxy-original') const isStateProxy = (value: any) => !!value && !!value[ORIGINAL_STATE] const stateProxyMap = new WeakMap<object, object>() const createStateProxy = <State extends object>( state: State, reducerMap: Partial<Record<PropertyKey, Reducer>>, initialStateCache: Record<PropertyKey, unknown>, ) => getOrInsertComputed( stateProxyMap, state, () => new Proxy(state, { get: (target, prop, receiver) => { if (prop === ORIGINAL_STATE) return target const result = Reflect.get(target, prop, receiver) if (typeof result === 'undefined') { const cached = initialStateCache[prop] if (typeof cached !== 'undefined') return cached const reducer = reducerMap[prop] if (reducer) { // ensure action type is random, to prevent reducer treating it differently const reducerResult = reducer(undefined, { type: nanoid() }) if (typeof reducerResult === 'undefined') { throw new Error( `The slice reducer for key "${prop.toString()}" returned undefined when called for selector(). ` + `If the state passed to the reducer is undefined, you must ` + `explicitly return the initial state. The initial state may ` + `not be undefined. If you don't want to set a value for this reducer, ` + `you can use null instead of undefined.`, ) } initialStateCache[prop] = reducerResult return reducerResult } } return result }, }), ) as State const original = (state: any) => { if (!isStateProxy(state)) { throw new Error('original must be used on state Proxy') } return state[ORIGINAL_STATE] } const emptyObject = {} const noopReducer: Reducer<Record<string, any>> = (state = emptyObject) => state export function combineSlices<Slices extends Array<AnySliceLike | ReducerMap>>( ...slices: Slices ): CombinedSliceReducer<Id<InitialState<Slices>>> { const reducerMap = Object.fromEntries<Reducer>(getReducers(slices)) const getReducer = () => Object.keys(reducerMap).length ? combineReducers(reducerMap) : noopReducer let reducer = getReducer() function combinedReducer( state: Record<string, unknown>, action: UnknownAction, ) { return reducer(state, action) } combinedReducer.withLazyLoadedSlices = () => combinedReducer const initialStateCache: Record<PropertyKey, unknown> = {} const inject = ( slice: AnySliceLike, config: InjectConfig = {}, ): typeof combinedReducer => { const { reducerPath, reducer: reducerToInject } = slice const currentReducer = reducerMap[reducerPath] if ( !config.overrideExisting && currentReducer && currentReducer !== reducerToInject ) { if ( typeof process !== 'undefined' && process.env.NODE_ENV === 'development' ) { console.error( `called \`inject\` to override already-existing reducer ${reducerPath} without specifying \`overrideExisting: true\``, ) } return combinedReducer } if (config.overrideExisting && currentReducer !== reducerToInject) { delete initialStateCache[reducerPath] } reducerMap[reducerPath] = reducerToInject reducer = getReducer() return combinedReducer } const selector = Object.assign( function makeSelector<State extends object, RootState, Args extends any[]>( selectorFn: (state: State, ...args: Args) => any, selectState?: (rootState: RootState, ...args: Args) => State, ) { return function selector(state: State, ...args: Args) { return selectorFn( createStateProxy( selectState ? selectState(state as any, ...args) : state, reducerMap, initialStateCache, ), ...args, ) } }, { original }, ) return Object.assign(combinedReducer, { inject, selector }) as any }