diff --git a/README.md b/README.md index 5894bef..2d79408 100644 --- a/README.md +++ b/README.md @@ -760,6 +760,7 @@ createReducer(initialState) .handleAction([actionCreator1, actionCreator2, ...actionCreatorN], reducer) .handleType(type, reducer) .handleType([type1, type2, ...typeN], reducer) + .defaultHandler(reducer) ``` Examples: @@ -805,6 +806,28 @@ const counterReducer = createReducer(0) .handleType('INCREMENT', (state, _) => state + 1); ``` +**Default handler:** +```ts +type State = boolean; +type Action = + | { type: 'REQUEST_START' } + | { type: 'REQUEST_SUCCESS' } + | { type: 'REQUEST_FAILURE' }; + +const loadingReducer = createReducer(false) + .handleType('REQUEST_START', () => true) + .defaultHandler(() => false); +``` + +The default handler runs for actions that do not have an explicit handler. It +does not run for Redux internal actions unless you pass `true` as the +second argument: +```ts +const reducer = createReducer(false) + .handleType('REQUEST_START', () => true) + .defaultHandler(() => false, true); +``` + **Extend or compose reducers - every operation is completely typesafe:** ```ts const newCounterReducer = createReducer(0) diff --git a/src/__snapshots__/create-reducer.spec.ts.snap b/src/__snapshots__/create-reducer.spec.ts.snap index 88ba316..f479bfe 100644 --- a/src/__snapshots__/create-reducer.spec.ts.snap +++ b/src/__snapshots__/create-reducer.spec.ts.snap @@ -8,7 +8,11 @@ exports[`Type refinement checks action (type) should match snapshot 3`] = `"T.Em exports[`Type refinement checks action (type) should match snapshot 4`] = `"T.EmptyAction<\\"foo4\\">"`; -exports[`Type refinement checks reducer.handlers (type) should match snapshot 1`] = `"Record<\\"foo1\\" | \\"foo2\\" | \\"foo3\\" | \\"foo4\\", (state: { foo: string | null; }, action: T.PayloadAction<\\"foo1\\", string> | T.PayloadAction<\\"foo2\\", string> | T.EmptyAction<\\"foo3\\"> | T.EmptyAction<\\"foo4\\">) => { foo: string | null; }>"`; +exports[`Type refinement checks action (type) should match snapshot 5`] = `"T.EmptyAction<\\"foo5\\">"`; + +exports[`Type refinement checks reducer.handlers (type) should match snapshot 1`] = `"Record<\\"foo1\\" | \\"foo2\\" | \\"foo3\\" | \\"foo4\\", (state: { foo: string | null; }, action: T.PayloadAction<\\"foo1\\", string> | T.PayloadAction<\\"foo2\\", string> | T.EmptyAction<\\"foo3\\"> | T.EmptyAction<\\"foo4\\"> | T.EmptyAction<\\"foo5\\">) => { foo: string | null; }>"`; + +exports[`Type refinement checks reducerDefaultResult (type) should match snapshot 1`] = `"{ foo: string | null; }"`; exports[`Type refinement checks reducerResult (type) should match snapshot 1`] = `"{ foo: string | null; }"`; @@ -26,6 +30,10 @@ exports[`Type refinement checks state (type) should match snapshot 3`] = `"{ foo exports[`Type refinement checks state (type) should match snapshot 4`] = `"{ foo: string | null; }"`; +exports[`Type refinement checks state (type) should match snapshot 5`] = `"{ foo: string | null; }"`; + +exports[`With Action Creators action (type) should match snapshot 1`] = `"T.EmptyAction<\\"INCREMENT\\"> | T.EmptyAction<\\"DECREMENT\\">"`; + exports[`With Action Creators counterReducer1.handlers (type) should match snapshot 1`] = `"Record<\\"ADD\\" | \\"INCREMENT\\", (state: number, action: T.PayloadAction<\\"ADD\\", number> | T.EmptyAction<\\"INCREMENT\\"> | T.EmptyAction<\\"DECREMENT\\">) => number>"`; exports[`With Action Creators counterReducer2.handlers (type) should match snapshot 1`] = `"Record<\\"ADD\\" | \\"INCREMENT\\", (state: number, action: T.PayloadAction<\\"ADD\\", number> | T.EmptyAction<\\"INCREMENT\\"> | T.EmptyAction<\\"DECREMENT\\">) => number>"`; @@ -34,6 +42,10 @@ exports[`With Action Creators counterReducer3.handlers (type) should match snaps exports[`With Action Creators counterReducer4.handlers (type) should match snapshot 1`] = `"Record<\\"INCREMENT\\", (state: number, action: T.PayloadAction<\\"ADD\\", number> | T.EmptyAction<\\"INCREMENT\\"> | T.EmptyAction<\\"DECREMENT\\">) => number>"`; +exports[`With Action Creators counterReducer5.handlers (type) should match snapshot 1`] = `"Record<\\"ADD\\", (state: number, action: T.PayloadAction<\\"ADD\\", number> | T.EmptyAction<\\"INCREMENT\\"> | T.EmptyAction<\\"DECREMENT\\">) => number>"`; + +exports[`With Action Types action (type) should match snapshot 1`] = `"T.EmptyAction<\\"INCREMENT\\"> | T.EmptyAction<\\"DECREMENT\\">"`; + exports[`With Action Types counterReducer1.handlers (type) should match snapshot 1`] = `"Record<\\"ADD\\" | \\"INCREMENT\\", (state: number, action: T.PayloadAction<\\"ADD\\", number> | T.EmptyAction<\\"INCREMENT\\"> | T.EmptyAction<\\"DECREMENT\\">) => number>"`; exports[`With Action Types counterReducer2.handlers (type) should match snapshot 1`] = `"Record<\\"ADD\\" | \\"INCREMENT\\", (state: number, action: T.PayloadAction<\\"ADD\\", number> | T.EmptyAction<\\"INCREMENT\\"> | T.EmptyAction<\\"DECREMENT\\">) => number>"`; @@ -41,3 +53,5 @@ exports[`With Action Types counterReducer2.handlers (type) should match snapshot exports[`With Action Types counterReducer3.handlers (type) should match snapshot 1`] = `"Record<\\"ADD\\", (state: number, action: T.PayloadAction<\\"ADD\\", number> | T.EmptyAction<\\"INCREMENT\\"> | T.EmptyAction<\\"DECREMENT\\">) => number>"`; exports[`With Action Types counterReducer4.handlers (type) should match snapshot 1`] = `"Record<\\"INCREMENT\\", (state: number, action: T.PayloadAction<\\"ADD\\", number> | T.EmptyAction<\\"INCREMENT\\"> | T.EmptyAction<\\"DECREMENT\\">) => number>"`; + +exports[`With Action Types counterReducer5.handlers (type) should match snapshot 1`] = `"Record<\\"ADD\\", (state: number, action: T.PayloadAction<\\"ADD\\", number> | T.EmptyAction<\\"INCREMENT\\"> | T.EmptyAction<\\"DECREMENT\\">) => number>"`; diff --git a/src/create-reducer.spec.snap.ts b/src/create-reducer.spec.snap.ts index ea9bd81..df08db9 100644 --- a/src/create-reducer.spec.snap.ts +++ b/src/create-reducer.spec.snap.ts @@ -68,6 +68,22 @@ const initialState = 0; // @dts-jest:pass Object.keys({ ...emptyReducer.handlers }); // => [] + const counterReducer5 = emptyReducer + .handleAction(add, (state, action) => state + action.payload) + .defaultHandler((state, action) => { + // @dts-jest:pass:snap -> T.EmptyAction<"INCREMENT"> | T.EmptyAction<"DECREMENT"> + action; + + return state - 1; + }); + + // @dts-jest:pass:snap -> Record<"ADD", (state: number, action: T.PayloadAction<"ADD", number> | T.EmptyAction<"INCREMENT"> | T.EmptyAction<"DECREMENT">) => number> + counterReducer5.handlers; + // @dts-jest:pass + Object.keys({ ...counterReducer5.handlers }); // => [ "ADD"] + // @dts-jest:pass + Object.keys({ ...emptyReducer.handlers }); // => [] + { [ counterReducer1, @@ -93,6 +109,8 @@ const initialState = 0; // @dts-jest:pass fn(0, add(4)); // => 4 }); + counterReducer5(0, decrement()); + counterReducer5(0, add(4)); } } @@ -111,6 +129,7 @@ const initialState = 0; foo2: createAction('foo2')(), foo3: createAction('foo3')(), foo4: createAction('foo4')(), + foo5: createAction('foo5')(), }; type Action = ActionType; @@ -159,9 +178,20 @@ const initialState = 0; ...state, foo: 'empty', }; + }) + .defaultHandler((state, action) => { + // @dts-jest:pass:snap -> { foo: string | null; } + state; + // @dts-jest:pass:snap -> T.EmptyAction<"foo5"> + action; + + return { + ...state, + foo: 'default', + }; }); - // @dts-jest:pass:snap -> Record<"foo1" | "foo2" | "foo3" | "foo4", (state: { foo: string | null; }, action: T.PayloadAction<"foo1", string> | T.PayloadAction<"foo2", string> | T.EmptyAction<"foo3"> | T.EmptyAction<"foo4">) => { foo: string | null; }> + // @dts-jest:pass:snap -> Record<"foo1" | "foo2" | "foo3" | "foo4", (state: { foo: string | null; }, action: T.PayloadAction<"foo1", string> | T.PayloadAction<"foo2", string> | T.EmptyAction<"foo3"> | T.EmptyAction<"foo4"> | T.EmptyAction<"foo5">) => { foo: string | null; }> reducer.handlers; { @@ -180,6 +210,10 @@ const initialState = 0; // @dts-jest:pass:snap -> { foo: string | null; } reducerResult; // => { foo: "empty" } }); + + const reducerDefaultResult = reducer(defaultState, actions2.foo5()); + // @dts-jest:pass:snap -> { foo: string | null; } + reducerDefaultResult; // => { foo: "default" } } } @@ -230,6 +264,21 @@ const initialState = 0; // @dts-jest:pass Object.keys({ ...reducerTest.handlers }); // => [] + const counterReducer5 = reducerTest + .handleType('ADD', (state, action) => state + action.payload) + .defaultHandler((state, action) => { + // @dts-jest:pass:snap -> T.EmptyAction<"INCREMENT"> | T.EmptyAction<"DECREMENT"> + action; + + return state - 1; + }); + // @dts-jest:pass:snap -> Record<"ADD", (state: number, action: T.PayloadAction<"ADD", number> | T.EmptyAction<"INCREMENT"> | T.EmptyAction<"DECREMENT">) => number> + counterReducer5.handlers; + // @dts-jest:pass + Object.keys({ ...counterReducer5.handlers }); // => [ "ADD"] + // @dts-jest:pass + Object.keys({ ...reducerTest.handlers }); // => [] + { [ counterReducer1, @@ -254,5 +303,41 @@ const initialState = 0; // @dts-jest:pass fn(0, add(4)); // => 4 }); + counterReducer5(0, increment()); + counterReducer5(0, add(4)); } } + +describe('defaultHandler', () => { + it('handles actions without an explicit handler', () => { + const reducer = createReducer>(0) + .handleAction(add, (state, action) => state + action.payload) + .defaultHandler(state => state - 1); + + expect(reducer(0, add(4))).toBe(4); + expect(reducer(0, increment())).toBe(-1); + expect(reducer(0, decrement())).toBe(-1); + }); + + it('does not run for Redux internal actions by default', () => { + const reducer = createReducer>(0) + .handleAction(add, (state, action) => state + action.payload) + .defaultHandler(() => 100); + + expect(reducer(undefined, { type: '@@redux/INIT.test' } as any)).toBe(0); + expect( + reducer(undefined, { type: '@@redux/PROBE_UNKNOWN_ACTION.test' } as any) + ).toBe(0); + }); + + it('can opt into running for Redux internal actions', () => { + const reducer = createReducer>(0) + .handleAction(add, (state, action) => state + action.payload) + .defaultHandler(() => 100, true); + + expect(reducer(undefined, { type: '@@redux/INIT.test' } as any)).toBe(100); + expect( + reducer(undefined, { type: '@@redux/PROBE_UNKNOWN_ACTION.test' } as any) + ).toBe(100); + }); +}); diff --git a/src/create-reducer.spec.ts b/src/create-reducer.spec.ts index c50bf90..4f8f62a 100644 --- a/src/create-reducer.spec.ts +++ b/src/create-reducer.spec.ts @@ -68,6 +68,22 @@ const initialState = 0; // @dts-jest:pass Object.keys({ ...emptyReducer.handlers }); // => [] + const counterReducer5 = emptyReducer + .handleAction(add, (state, action) => state + action.payload) + .defaultHandler((state, action) => { + // @dts-jest:pass:snap + action; + + return state - 1; + }); + + // @dts-jest:pass:snap + counterReducer5.handlers; + // @dts-jest:pass + Object.keys({ ...counterReducer5.handlers }); // => [ "ADD"] + // @dts-jest:pass + Object.keys({ ...emptyReducer.handlers }); // => [] + { [ counterReducer1, @@ -93,6 +109,8 @@ const initialState = 0; // @dts-jest:pass fn(0, add(4)); // => 4 }); + counterReducer5(0, decrement()); + counterReducer5(0, add(4)); } } @@ -111,6 +129,7 @@ const initialState = 0; foo2: createAction('foo2')(), foo3: createAction('foo3')(), foo4: createAction('foo4')(), + foo5: createAction('foo5')(), }; type Action = ActionType; @@ -159,6 +178,17 @@ const initialState = 0; ...state, foo: 'empty', }; + }) + .defaultHandler((state, action) => { + // @dts-jest:pass:snap + state; + // @dts-jest:pass:snap + action; + + return { + ...state, + foo: 'default', + }; }); // @dts-jest:pass:snap @@ -180,6 +210,10 @@ const initialState = 0; // @dts-jest:pass:snap reducerResult; // => { foo: "empty" } }); + + const reducerDefaultResult = reducer(defaultState, actions2.foo5()); + // @dts-jest:pass:snap + reducerDefaultResult; // => { foo: "default" } } } @@ -230,6 +264,21 @@ const initialState = 0; // @dts-jest:pass Object.keys({ ...reducerTest.handlers }); // => [] + const counterReducer5 = reducerTest + .handleType('ADD', (state, action) => state + action.payload) + .defaultHandler((state, action) => { + // @dts-jest:pass:snap + action; + + return state - 1; + }); + // @dts-jest:pass:snap + counterReducer5.handlers; + // @dts-jest:pass + Object.keys({ ...counterReducer5.handlers }); // => [ "ADD"] + // @dts-jest:pass + Object.keys({ ...reducerTest.handlers }); // => [] + { [ counterReducer1, @@ -254,5 +303,41 @@ const initialState = 0; // @dts-jest:pass fn(0, add(4)); // => 4 }); + counterReducer5(0, increment()); + counterReducer5(0, add(4)); } } + +describe('defaultHandler', () => { + it('handles actions without an explicit handler', () => { + const reducer = createReducer>(0) + .handleAction(add, (state, action) => state + action.payload) + .defaultHandler(state => state - 1); + + expect(reducer(0, add(4))).toBe(4); + expect(reducer(0, increment())).toBe(-1); + expect(reducer(0, decrement())).toBe(-1); + }); + + it('does not run for Redux internal actions by default', () => { + const reducer = createReducer>(0) + .handleAction(add, (state, action) => state + action.payload) + .defaultHandler(() => 100); + + expect(reducer(undefined, { type: '@@redux/INIT.test' } as any)).toBe(0); + expect( + reducer(undefined, { type: '@@redux/PROBE_UNKNOWN_ACTION.test' } as any) + ).toBe(0); + }); + + it('can opt into running for Redux internal actions', () => { + const reducer = createReducer>(0) + .handleAction(add, (state, action) => state + action.payload) + .defaultHandler(() => 100, true); + + expect(reducer(undefined, { type: '@@redux/INIT.test' } as any)).toBe(100); + expect( + reducer(undefined, { type: '@@redux/PROBE_UNKNOWN_ACTION.test' } as any) + ).toBe(100); + }); +}); diff --git a/src/create-reducer.ts b/src/create-reducer.ts index f0e042d..5f89cc8 100644 --- a/src/create-reducer.ts +++ b/src/create-reducer.ts @@ -24,6 +24,11 @@ export type HandleActionChainApi< (state: TState, action: TRootAction) => TState >; handleAction: HandleActionChainApi; + defaultHandler: HandleDefaultActionChainApi< + TState, + TOutputAction, + TRootAction + >; } : Reducer & { handlers: Record< @@ -50,6 +55,11 @@ export type HandleTypeChainApi< (state: TState, action: TRootAction) => TState >; handleType: HandleTypeChainApi; + defaultHandler: HandleDefaultActionChainApi< + TState, + TOutputAction, + TRootAction + >; } : Reducer & { handlers: Record< @@ -58,6 +68,20 @@ export type HandleTypeChainApi< >; }; +export type HandleDefaultActionChainApi< + TState, + TInputAction extends Action, + TRootAction extends Action +> = ( + reducer: (state: TState, action: TInputAction) => TState, + executeAtInitialization?: boolean +) => Reducer & { + handlers: Record< + Exclude['type'], + (state: TState, action: TRootAction) => TState + >; +}; + type GetAction< TAction extends Action, TType extends TAction['type'] @@ -74,12 +98,16 @@ type RootAction = Types extends { RootAction: infer T } ? T : any; export function createReducer( initialState: TState, - initialHandlers: InitialHandler = {} + initialHandlers: InitialHandler = {}, + defaultHandler?: (state: TState, action: TRootAction) => TState, + runDefaultHandlerAtInitialization: boolean = false ) { const handlers: any = { ...initialHandlers, }; + const internalReduxActionTypes = /^@@redux\//; + const rootReducer: Reducer = ( state = initialState, action: TRootAction @@ -92,6 +120,12 @@ export function createReducer( ); } return reducer(state, action); + } else if ( + defaultHandler && + (runDefaultHandlerAtInitialization || + !internalReduxActionTypes.test(action.type)) + ) { + return defaultHandler(state, action); } else { return state; } @@ -121,18 +155,35 @@ export function createReducer( ) .forEach(type => (newHandlers[type] = reducer)); - return createReducer(initialState, { - ...handlers, - ...newHandlers, - }); + return createReducer( + initialState, + { + ...handlers, + ...newHandlers, + }, + defaultHandler, + runDefaultHandlerAtInitialization + ); }) as | HandleActionChainApi | HandleTypeChainApi; + const defaultReducerHandler = (( + reducer: (state: TState, action: TRootAction) => TState, + executeAtInitialization: boolean = false + ) => + createReducer( + initialState, + handlers, + reducer, + executeAtInitialization + )) as HandleDefaultActionChainApi; + const chainApi = Object.assign(rootReducer, { handlers: { ...handlers }, handleAction: reducerHandler, handleType: reducerHandler, + defaultHandler: defaultReducerHandler, }) as Reducer & Readonly<{ handlers: InitialHandler; @@ -142,6 +193,9 @@ export function createReducer( handleType: [unknown] extends [TRootAction] ? any : HandleTypeChainApi; + defaultHandler: [unknown] extends [TRootAction] + ? any + : HandleDefaultActionChainApi; }>; return chainApi;