diff --git a/src/index.ts b/src/index.ts index c1167d2..99f3a57 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,8 @@ export { export { reducer, + actionReducer, + streamReducer, combineReducers, Reducer, RegisteredReducer, diff --git a/src/internal/testing/mock.ts b/src/internal/testing/mock.ts index 50321ad..59f6562 100644 --- a/src/internal/testing/mock.ts +++ b/src/internal/testing/mock.ts @@ -1,5 +1,5 @@ import { actionCreator } from '../../actionCreator'; -import { reducer } from '../../reducer'; +import { actionReducer } from '../../reducer'; import { _namespaceAction } from '../../namespace'; const incrementOne = actionCreator('[increment] one'); @@ -14,12 +14,17 @@ const throwErrorFn = (): number => { const ERROR = 'error'; const namespace = 'namespace'; -const handleOne = reducer(incrementOne, incrementOneHandler); -const handleMany = reducer( +const handleOne = actionReducer(incrementOne, incrementOneHandler); +const handleMany = actionReducer( + incrementMany, + (accumulator: number, increment) => accumulator + increment +); +const handleManyExplicitTypes = actionReducer( incrementMany, (accumulator: number, increment) => accumulator + increment ); -const handleDecrementWithError = reducer(decrement, throwErrorFn); + +const handleDecrementWithError = actionReducer(decrement, throwErrorFn); export const incrementMocks = { error: ERROR, @@ -32,7 +37,12 @@ export const incrementMocks = { handlers: { incrementOne: incrementOneHandler, }, - reducers: { handleOne, handleMany, handleDecrementWithError }, + reducers: { + handleOne, + handleMany, + handleManyExplicitTypes, + handleDecrementWithError, + }, marbles: { errors: { e: ERROR, diff --git a/src/internal/types.ts b/src/internal/types.ts index c65ea66..078ec62 100644 --- a/src/internal/types.ts +++ b/src/internal/types.ts @@ -21,6 +21,9 @@ export interface ActionCreatorCommon { * * This type allows for inferring overlap of the payload type between multiple * action creators with different payloads. + * + * @deprecated + * v2.6.0 Use ActionCreator type instead */ export interface UnknownActionCreatorWithPayload extends ActionCreatorCommon { @@ -34,6 +37,9 @@ export interface UnknownActionCreatorWithPayload * This type has payload as an optional argument to the action creator function * and has return type `UnknownAction`. It's useful when you need to define a * generic action creator that might create actions with or without actions. + * + * @deprecated + * v2.6.0 Use ActionCreator type instead */ export interface UnknownActionCreator extends ActionCreatorCommon { (payload?: any): UnknownAction; diff --git a/src/reducer.spec.ts b/src/reducer.spec.ts index d3b8a66..f149b33 100644 --- a/src/reducer.spec.ts +++ b/src/reducer.spec.ts @@ -6,7 +6,7 @@ import { incrementMocks } from './internal/testing/mock'; const { reducers, actionCreators, handlers } = incrementMocks; const { actions, words, numbers, errors } = incrementMocks.marbles; const reducerArray = Object.values(reducers); -const alwaysReset = reducer( +const alwaysReset = reducer( [ actionCreators.incrementOne, actionCreators.incrementMany, diff --git a/src/reducer.ts b/src/reducer.ts index 36e0924..aa41a78 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -8,12 +8,13 @@ import { pipe, } from 'rxjs'; import { filter, map, mergeWith, scan } from 'rxjs/operators'; -import { +import type { UnknownAction, UnknownActionCreator, UnknownActionCreatorWithPayload, VoidPayload, } from './internal/types'; +import type { ActionCreator } from './types/ActionCreator'; import { defaultErrorSubject } from './internal/defaultErrorSubject'; import { ofType } from './operators/operators'; import { isObservableInput } from './isObservableInput'; @@ -29,7 +30,7 @@ export type Reducer = ( type RegisteredActionReducer = Reducer & { trigger: { - actions: UnknownActionCreator[]; + actions: ActionCreator[]; }; }; type RegisteredStreamReducer = Reducer & { @@ -43,12 +44,8 @@ export type RegisteredReducer = Reducer< Payload > & { trigger: - | { - actions: UnknownActionCreator[]; - } - | { - source$: Observable; - }; + | { actions: ActionCreator[] } + | { source$: Observable }; }; const isActionReducer = ( @@ -61,9 +58,89 @@ const isStreamReducer = ( ): reducerFn is RegisteredStreamReducer => 'source$' in reducerFn.trigger; -type ObservableLike = Observable | InteropObservable; +/** + * A stream reducer is a stream operator which updates the state of a given stream with the last + * emitted state of another stream, meaning it reduces the state of a given stream over another + * stream. + * + * Another way of looking at it is in terms of an action reducer this avoids creating a new action + * and dispatching it manually on every emit from the source observable. This treats the observable + * state as the action. + * + * ```ts + * // Listen for changes on context$ + * streamReducer( + * context$, + * (state, context) => { + * // context changed! + * }) + * + * // Listen for specific changes on context$ + * streamReducer( + * context$.pipe( + * map(context => ({ id })), + * distinctUntilChanged() + * ), + * (state, contextId) => { + * // contextId changed! + * }) + * ``` + * + * @param source The observable that the reducer function should be subscribed to, which act as + * the "action" of the reducer. + * @param reducerFn The reducer function with signature: + * (prevState, observableInput) => nextState + * @returns A wrapped reducer function for use with persistedReducedStream, combineReducers etc. + */ +export const streamReducer = ( + source: ObservableInput, + reducerFn: Reducer +): RegisteredReducer => { + const wrappedStreamReducer = ( + state: State, + emittedState: EmittedState, + namespace?: string + ) => reducerFn(state, emittedState, namespace); + + wrappedStreamReducer.trigger = { + source$: from(source), + }; + + return wrappedStreamReducer; +}; + +/** + * A action reducer is a stream operator which triggers its reducer when a + * relevant action is dispatched to the action$ + * + * Typing your reducer: + * - The _preferred_ way to type this is to let Typescript infer both State and + * Payload from your reducerFn and actionCreator respectively. + * - Alternatively you can provide both type arguments explicitly + * We do not default the payload to void in order to encourage inference. + * + * @param actionCreator The actionCreator that creates the action that should run the reducer + * @param reducerFn The reducer function with signature: (prevState, action) => newState + * @returns A wrapped reducer function for use with persistedReducedStream, combineReducers etc. + */ +export const actionReducer = ( + actionCreator: ActionCreator, + reducerFn: Reducer +): RegisteredReducer => { + const wrappedActionReducer = ( + state: State, + payload: Payload, + namespace?: string + ) => reducerFn(state, payload, namespace); + + wrappedActionReducer.trigger = { + actions: [actionCreator], + }; -type ReducerCreator = { + return wrappedActionReducer; +}; + +type DeprecatedReducerCreator = { /** * Define a reducer for a stream * @@ -76,7 +153,7 @@ type ReducerCreator = { * called directly as if it was the `reducer` parameter itself. */ ( - source$: ObservableLike, + source$: Observable | InteropObservable, reducer: Reducer ): RegisteredReducer; @@ -94,6 +171,7 @@ type ReducerCreator = { * @returns A registered reducer that can be passed into `combineReducers`, or * called directly as if it was the `reducer` parameter itself. */ + ( actionCreator: UnknownActionCreatorWithPayload[], reducer: Reducer @@ -167,12 +245,18 @@ type ReducerCreator = { ): RegisteredReducer; }; -export const reducer: ReducerCreator = ( +/** + * @deprecated + * v2.6.0, use actionReducer or streamReducer instead. + * If using multi-action reducers you have to split them into individual reducers + */ +export const reducer: DeprecatedReducerCreator = ( trigger: UnknownActionCreator | UnknownActionCreator[] | ObservableInput, reducerFn: Reducer ) => { const wrapper = (state: State, payload: any, namespace?: string) => reducerFn(state, payload, namespace); + if (!Array.isArray(trigger) && isObservableInput(trigger)) { wrapper.trigger = { source$: from>(trigger),