Repository: epeli/immer-reducer Branch: master Commit: e1a2d471e0db Files: 18 Total size: 49.2 KB Directory structure: gitextract_sosauq7j/ ├── .gitignore ├── .prettierrc ├── .travis.yml ├── .vscode/ │ └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __dtslint__/ │ ├── generic-reducers.dtslint.ts │ └── immer-reducer.dtslint.ts ├── __tests__/ │ ├── immer-reducer.test.tsx │ └── use-reducer-integration.test.tsx ├── jest.config.js ├── package.json ├── src/ │ └── immer-reducer.ts ├── tsconfig.build.json ├── tsconfig.dtslint.json ├── tsconfig.json └── tslint.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ /node_modules /package-lock.json /lib ================================================ FILE: .prettierrc ================================================ { "bracketSpacing": false, "trailingComma": "all", "tabWidth": 4 } ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - 10 - 8 script: npm test ================================================ FILE: .vscode/settings.json ================================================ { "typescript.tsdk": "node_modules/typescript/lib" } ================================================ FILE: CHANGELOG.md ================================================ See ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Esa-Matti Suuronen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # immer-reducer Type-safe and terse reducers with Typescript for React Hooks and Redux using [Immer](https://immerjs.github.io/immer/)! ## 📦 Install npm install immer-reducer You can also install [eslint-plugin-immer-reducer](https://github.com/skoshy/eslint-plugin-immer-reducer) to help you avoid errors when writing your reducer. ## 💪 Motivation Turn this 💩 💩 💩 ```ts interface SetFirstNameAction { type: "SET_FIRST_NAME"; firstName: string; } interface SetLastNameAction { type: "SET_LAST_NAME"; lastName: string; } type Action = SetFirstNameAction | SetLastNameAction; function reducer(action: Action, state: State): State { switch (action.type) { case "SET_FIRST_NAME": return { ...state, user: { ...state.user, firstName: action.firstName, }, }; case "SET_LAST_NAME": return { ...state, user: { ...state.user, lastName: action.lastName, }, }; default: return state; } } ``` ✨✨ Into this! ✨✨ ```ts import {ImmerReducer} from "immer-reducer"; class MyImmerReducer extends ImmerReducer { setFirstName(firstName: string) { this.draftState.user.firstName = firstName; } setLastName(lastName: string) { this.draftState.user.lastName = lastName; } } ``` 🔥🔥 **Without losing type-safety!** 🔥🔥 Oh, and you get the action creators for free! 🤗 🎂 ## 📖 Usage Generate Action Creators and the actual reducer function for Redux from the class with ```ts import {createStore} from "redux"; import {createActionCreators, createReducerFunction} from "immer-reducer"; const initialState: State = { user: { firstName: "", lastName: "", }, }; const ActionCreators = createActionCreators(MyImmerReducer); const reducerFunction = createReducerFunction(MyImmerReducer, initialState); const store = createStore(reducerFunction); ``` Dispatch some actions ```ts store.dispatch(ActionCreators.setFirstName("Charlie")); store.dispatch(ActionCreators.setLastName("Brown")); expect(store.getState().user.firstName).toEqual("Charlie"); expect(store.getState().user.lastName).toEqual("Brown"); ``` ## 🌟 Typed Action Creators! The generated `ActionCreator` object respect the types used in the class ```ts const action = ActionCreators.setFirstName("Charlie"); action.payload; // Has the type of string ActionCreators.setFirstName(1); // Type error. Needs string. ActionCreators.setWAT("Charlie"); // Type error. Unknown method ``` If the reducer class where to have a method which takes more than one argument the payload would be array of the arguments ```ts // In the Reducer class: // setName(firstName: string, lastName: string) {} const action = ActionCreators.setName("Charlie", "Brown"); action.payload; // will have value ["Charlie", "Brown"] and type [string, string] ``` The reducer function is also typed properly ```ts const reducer = createReducerFunction(MyImmerReducer); reducer(initialState, ActionCreators.setFirstName("Charlie")); // OK reducer(initialState, {type: "WAT"}); // Type error reducer({wat: "bad state"}, ActionCreators.setFirstName("Charlie")); // Type error ``` ## ⚓ React Hooks Because the `useReducer()` API in React Hooks is the same as with Redux Reducers immer-reducer can be used with as is. ```tsx const initialState = {message: ""}; class ReducerClass extends ImmerReducer { setMessage(message: string) { this.draftState.message = message; } } const ActionCreators = createActionCreators(ReducerClass); const reducerFunction = createReducerFunction(ReducerClass); function Hello() { const [state, dispatch] = React.useReducer(reducerFunction, initialState); return ( ); } ``` The returned state and dispatch functions will be typed as you would expect. ## 🤔 How Under the hood the class is deconstructed to following actions: ```js { type: "IMMER_REDUCER:MyImmerReducer#setFirstName", payload: "Charlie", } { type: "IMMER_REDUCER:MyImmerReducer#setLastName", payload: "Brown", } { type: "IMMER_REDUCER:MyImmerReducer#setName", payload: ["Charlie", "Brown"], args: true } ``` So the class and method names become the Redux Action Types and the method arguments become the action payloads. The reducer function will then match these actions against the class and calls the appropriate methods with the payload array spread to the arguments. 🚫 The format of the `action.type` string is internal to immer-reducer. If you need to detect the actions use the provided type guards. The generated reducer function executes the methods inside the `produce()` function of Immer enabling the terse mutatable style updates. ## 🔄 Integrating with the Redux ecosystem To integrate for example with the side effects libraries such as [redux-observable](https://github.com/redux-observable/redux-observable/) and [redux-saga](https://github.com/redux-saga/redux-saga), you can access the generated action type using the `type` property of the action creator function. With redux-observable ```ts // Get the action name to subscribe to const setFirstNameActionTypeName = ActionCreators.setFirstName.type; // Get the action type to have a type safe Epic type SetFirstNameAction = ReturnType; const setFirstNameEpic: Epic = action$ => action$ .ofType(setFirstNameActionTypeName) .pipe( // action.payload - recognized as string map(action => action.payload.toUpperCase()), ... ); ``` With redux-saga ```ts function* watchFirstNameChanges() { yield takeEvery(ActionCreators.setFirstName.type, doStuff); } // or use the isActionFrom() to get all actions from a specific ImmerReducer // action creators object function* watchImmerActions() { yield takeEvery( (action: Action) => isActionFrom(action, MyImmerReducer), handleImmerReducerAction, ); } function* handleImmerReducerAction(action: Actions) { // `action` is a union of action types if (isAction(action, ActionCreators.setFirstName)) { // with action of setFirstName } } ``` **Warning:** Due to how immer-reducers action generation works, adding default parameters to the methods will NOT pass it to the action payload, which can make your reducer impure and the values will not be available in middlewares. ```ts class MyImmerReducer extends ImmerReducer { addItem (id: string = uuid()) { this.draftState.ids.push([id]) } } immerActions.addItem() // generates empty payload { payload: [] } ``` As a workaround, create custom action creator wrappers that pass the default parameters instead. ```ts class MyImmerReducer extends ImmerReducer { addItem (id) { this.draftState.ids.push([id]) } } const actions = { addItem: () => immerActions.addItem(id) } ``` It is also recommended to install the ESLint plugin in the "Install" section to alert you if you accidentally encounter this issue. ## 📚 Examples Here's a more complete example with redux-saga and [redux-render-prop](https://github.com/epeli/redux-render-prop): ## 🃏 Tips and Tricks You can replace the whole `draftState` with a new state if you'd like. This could be useful if you'd like to reset back to your initial state. ```ts import {ImmerReducer} from "immer-reducer"; const initialState: State = { user: { firstName: "", lastName: "", }, }; class MyImmerReducer extends ImmerReducer { // omitting other reducer methods reset() { this.draftState = initialState; } } ``` ## 📓 Helpers The module exports following helpers ### `function isActionFrom(action, ReducerClass)` Type guard for detecting whether the given action is generated by the given reducer class. The detected type will be union of actions the class generates. Example ```ts if (isActionFrom(someAction, ActionCreators)) { // someAction now has type of // { // type: "setFirstName"; // payload: string; // } | { // type: "setLastName"; // payload: string; // }; } ``` ### `function isAction(action, actionCreator)` Type guard for detecting specific actions generated by immer-reducer. Example ```ts if (isAction(someAction, ActionCreators.setFirstName)) { someAction.payload; // Type checks to `string` } ``` ### `type Actions` Get union of the action types generated by the ImmerReducer class Example ```ts type MyActions = Actions; // Is the same as type MyActions = | { type: "setFirstName"; payload: string; } | { type: "setLastName"; payload: string; }; ``` ### `function setPrefix(prefix: string)` The default prefix in the generated action types is `IMMER_REDUCER`. Call this customize it for your app. Example ```ts setPrefix("MY_APP"); ``` ### `function composeReducers(...reducers)` Utility that reduces actions by applying them through multiple reducers. This helps in allowing you to split up your reducer logic to multiple `ImmerReducer`s if they affect the same part of your state Example ```ts class MyNameReducer extends ImmerReducer { setFirstName(firstName: string) { this.draftState.firstName = firstName; } setLastName(lastName: string) { this.draftState.lastName = lastName; } } class MyAgeReducer extends ImmerReducer { setAge(age: number) { this.draftState.age = 8; } } export const reducer = composeReducers( createReducerFunction(MyNameReducer, initialState), createReducerFunction(MyAgeReducer, initialState) ) ``` ================================================ FILE: __dtslint__/generic-reducers.dtslint.ts ================================================ import { ImmerReducer, createReducerFunction, createActionCreators, ImmerReducerState, } from "../src/immer-reducer"; interface AssignFail { ___: "it should not be possible to assign to me"; } interface State { foo: { fooField1: string; fooField2: number; }; bar: { barField1: number[]; barField2: RegExp; }; } const initialState: State = { foo: { fooField1: "a", fooField2: 1, }, bar: { barField1: [1, 2], barField2: /re/, }, }; function createGenericReducer() { return class GenericReducer extends ImmerReducer { set(part: Partial) { Object.assign(this.draftState, part); } }; } const ReducerClassFoo = createGenericReducer(); const ReducerClassBar = createGenericReducer(); //////////////////// // Instance tests // //////////////////// const ins = new ReducerClassFoo(initialState.foo, initialState.foo); const state_test_1: State["foo"] = ins.state; const state_test_2: State["foo"] = ins.draftState; // cannot assign to wrong state (ie. was not any) // $ExpectError const state_test_3: AssignFail = ins.state; // $ExpectError const state_test_4: AssignFail = ins.draftState; ////////////////////////// // Action Creator tests // ////////////////////////// const ActionCreatorsFoo = createActionCreators(ReducerClassFoo); const ActionCreatorsBar = createActionCreators(ReducerClassBar); ActionCreatorsFoo.set({fooField1: "b"}); ActionCreatorsFoo.set({fooField2: 2}); ActionCreatorsBar.set({barField1: [8]}); ActionCreatorsBar.set({barField2: /ding/}); // Cannot set bad values // $ExpectError ActionCreatorsFoo.set({fooField1: 2}); // Cannot set unknown fields // $ExpectError ActionCreatorsFoo.set({bad: 2}); // Cannot set bar fields // $ExpectError ActionCreatorsFoo.set({barField1: [8]}); //////////////////////////// // Reducer function tests // //////////////////////////// const reducerFoo = createReducerFunction(ReducerClassFoo, initialState.foo); reducerFoo(initialState, ActionCreatorsFoo.set({fooField1: "c"})); // no bad actions allowed // $ExpectError reducerFoo(initialState, {type: "BAD_ACTION"}); // XXX bug! :( State is any here. This should fail! reducerFoo({bad: "state"}, ActionCreatorsFoo.set({fooField1: "c"})); // For some reason ImmerReducerState cannot infer state // from a generic class. Maybe this is a limitation in Typescript? type InferredState = ImmerReducerState; declare const inferredState: InferredState; // XXX! Should fail too! const anumber: AssignFail = inferredState; ================================================ FILE: __dtslint__/immer-reducer.dtslint.ts ================================================ import {Action, createStore, bindActionCreators} from "redux"; import { ImmerReducer, createActionCreators, createReducerFunction, isAction, Actions, isActionFrom, } from "../src/immer-reducer"; import {Dispatch} from "react"; import React from "react"; interface AssertNotAny { ___: "it should not be possible to assign to me"; } interface State { readonly foo: string; readonly bar: number; } class MyReducer extends ImmerReducer { setBoth(newFoo: string, newBar: number) { this.setBar(newBar); this.setFoo(newFoo); } setFoo(newFoo: string) { this.draftState.foo = newFoo; } setBar(newBar: number) { this.draftState.bar = newBar; } setFooStatic() { this.draftState.foo = "static"; } } //////////////////// // Test action types //////////////////// const ActionCreators = createActionCreators(MyReducer); // Action creator return Action Object const action: { type: "setBar"; payload: number; } = ActionCreators.setBar(3); // the action creator does no return any // $ExpectError const is_not_any: AssertNotAny = ActionCreators.setBar(3); // actions without payload const staticAction = ActionCreators.setFooStatic(); const staticPayload: [] = staticAction.payload; // Actions with multiple items in the payload const bothAction = ActionCreators.setBoth("foo", 1); const bothPayload: [string, number] = bothAction.payload; // Only function properties are picked // $ExpectError ActionCreators.draftState; // $ExpectError ActionCreators.state; // Do not allow bad argument types // $ExpectError ActionCreators.setBar("sdf"); // Do not allow bad method names // $ExpectError ActionCreators.setBad(3); ////////////////////// // Test reducer types ////////////////////// class BadReducer { dong() {} } // Cannot create action creators from random classes // $ExpectError createActionCreators(BadReducer); const reducer = createReducerFunction(MyReducer); // can create with proper initial state createReducerFunction(MyReducer, {foo: "", bar: 0}); // Bad state argument is not allowed // $ExpectError createReducerFunction(MyReducer, {bad: "state"}); const newState: State = reducer( {foo: "sdf", bar: 2}, { type: "setBar", payload: 3, }, ); // reducer does not return any // $ExpectError const no_any_state: AssertNotAny = reducer( {foo: "f", bar: 2}, { type: "setBar", payload: 3, }, ); // bad state for the reducer reducer( // $ExpectError {foo: "sdf", bar: "should be number"}, { type: "setBar", payload: 3, }, ); // Bad action object // $ExpectError reducer({foo: "sdf", bar: 2}, {}); // Bad payload type reducer( {foo: "sdf", bar: 2}, // $ExpectError { type: "setBar", payload: "should be number here", }, ); // Bad action type reducer( {foo: "sdf", bar: 2}, { // $ExpectError type: "bad", payload: 3, }, ); reducer({foo: "sdf", bar: 2}, ActionCreators.setBar(3)); class OtherReducer extends ImmerReducer { setDing(dong: string) { this.draftState.foo = dong; } } const OtherActionCreators = createActionCreators(OtherReducer); // Mixed reducer and action creators from different ImmerReducer classes // $ExpectError reducer({foo: "sdf", bar: 2}, OtherActionCreators.setDing("sdf")); // Action creator provides action type const actionType: "setBar" = ActionCreators.setBar.type; // $ExpectError const actionType_not_any: AssertNotAny = ActionCreators.setBar.type; ////////////////////// // Test isAction types ////////////////////// declare const unknownAction: {type: string}; if (isAction(unknownAction, ActionCreators.setBar)) { // $ExpectError const actione_not_any: AssertNotAny = unknownAction; const knownAction: { type: "setBar"; payload: number; } = unknownAction; // $ExpectError const nope: string = unknownAction.payload; } ///////////////////////////// // Test Actions<> type helper ///////////////////////////// class Reducer1 extends ImmerReducer { setFoo(newFoo: string) { this.draftState.foo = newFoo; } setBar(newBar: number) { this.draftState.bar = newBar; } } type MyActions = Actions; declare const someActions: MyActions; // $ExpectError const someActionsNotAny: AssertNotAny = someActions; const someActionsTest: | { type: "setFoo"; payload: string; } | { type: "setBar"; payload: number; } = someActions; type MyReducerActions = Actions; declare const myReducerActions: MyReducerActions; // $ExpectError const actions_not_any: AssertNotAny = myReducerActions; const actions_manual: | { type: "setFoo"; payload: string; } | { type: "setBar"; payload: number; } = myReducerActions; ////////////////////////// // Test isActionFrom types ////////////////////////// declare const someAction: Action; const ActionCreators1 = createActionCreators(Reducer1); if (isActionFrom(someAction, Reducer1)) { // $ExpectError const notany: AssertNotAny = someAction; const actions_manual: | { type: "setFoo"; payload: string; } | { type: "setBar"; payload: number; } = someAction; } test("Can work with bindActionCreators", () => { const initialState = {foo: ""}; const store = createStore(s => initialState); class Reducer extends ImmerReducer { setFoo(foo: string) {} } const ActionCreators = createActionCreators(Reducer); const boundActionCreators = bindActionCreators( ActionCreators, store.dispatch, ); }); test("can use with React.useReducer()", () => { const initialState = {foo: ""}; class Reducer extends ImmerReducer { setFoo(foo: string) {} } const ActionCreators = createActionCreators(Reducer); const reducerFuntion = createReducerFunction(Reducer); function Component1() { const [state, dispatch] = React.useReducer( reducerFuntion, initialState, ); const callback = () => { dispatch(ActionCreators.setFoo("test")); // $ExpectError dispatch("bad"); const foo: string = state.foo; // $ExpectError const bar: AssertNotAny = state.foo; }; return null; } }); ================================================ FILE: __tests__/immer-reducer.test.tsx ================================================ import { ImmerReducer, createReducerFunction, createActionCreators, composeReducers, setPrefix, _clearKnownClasses, isAction, isActionFrom, } from "../src/immer-reducer"; import {createStore, combineReducers, Action} from "redux"; beforeEach(_clearKnownClasses); afterEach(() => { setPrefix("IMMER_REDUCER"); }); test("can detect inherited actions", () => { class Parent extends ImmerReducer { setFoo(foo: string) {} } class Child extends Parent { setFoo2(foo: string) {} } const actions = createActionCreators(Child); expect(actions.setFoo).toBeTruthy(); expect(actions.setFoo2).toBeTruthy(); }); test("can create reducers", () => { const initialState = {foo: "bar"}; class TestReducer extends ImmerReducer { setFoo(foo: string) { this.draftState.foo = foo; } } const reducer = createReducerFunction(TestReducer); const store = createStore(reducer, initialState); expect(store.getState()).toEqual({foo: "bar"}); }); test("the reducer can return the initial state", () => { const initialState = {foo: "bar"}; class TestReducer extends ImmerReducer { setFoo(foo: string) { this.draftState.foo = foo; } } const reducer = createReducerFunction(TestReducer, initialState); const store = createStore(reducer); expect(store.getState()).toEqual({foo: "bar"}); }); test("can dispatch actions", () => { const initialState = {foo: "bar"}; class TestReducer extends ImmerReducer { noop() {} } const ActionCreators = createActionCreators(TestReducer); const reducer = createReducerFunction(TestReducer, initialState); const store = createStore(reducer); store.dispatch(ActionCreators.noop()); expect(store.getState()).toEqual({foo: "bar"}); }); test("can update state", () => { const initialState = {foo: "bar"}; class TestReducer extends ImmerReducer { setFoo(foo: string) { this.draftState.foo = foo; } } const ActionCreators = createActionCreators(TestReducer); const reducer = createReducerFunction(TestReducer, initialState); const store = createStore(reducer); store.dispatch(ActionCreators.setFoo("next")); expect(store.getState()).toEqual({foo: "next"}); }); test("can update state using multiple methods", () => { const initialState = {foo: "bar", bar: 1}; class TestReducer extends ImmerReducer { setFoo(foo: string) { this.draftState.foo = foo; } setBar(bar: number) { this.draftState.bar = bar; } setBoth(foo: string, bar: number) { this.setFoo(foo); this.setBar(bar); } } const ActionCreators = createActionCreators(TestReducer); const reducer = createReducerFunction(TestReducer, initialState); const store = createStore(reducer); store.dispatch(ActionCreators.setBoth("next", 2)); expect(store.getState()).toEqual({foo: "next", bar: 2}); }); test("the actual action type name is prefixed", () => { const initialState = {foo: "bar"}; class TestReducer extends ImmerReducer { setFoo(foo: string) { this.draftState.foo = foo; } } const ActionCreators = createActionCreators(TestReducer); const reducer = createReducerFunction(TestReducer, initialState); const reducerSpy: typeof reducer = jest.fn(reducer); const store = createStore(reducerSpy); store.dispatch(ActionCreators.setFoo("next")); expect(reducerSpy).toHaveBeenLastCalledWith( {foo: "bar"}, { payload: "next", type: "IMMER_REDUCER:TestReducer#setFoo", }, ); }); test("can add helpers to the class", () => { const initialState = {foo: 1, bar: 1}; class Helper { state: typeof initialState; constructor(state: typeof initialState) { this.state = state; } getCombined() { return this.state.foo + this.state.bar; } } class TestReducer extends ImmerReducer { helper = new Helper(this.state); combineToBar() { this.draftState.bar = this.helper.getCombined(); } } const ActionCreators = createActionCreators(TestReducer); const reducer = createReducerFunction(TestReducer, initialState); const store = createStore(reducer); store.dispatch(ActionCreators.combineToBar()); expect(store.getState()).toEqual({foo: 1, bar: 2}); }); test("can use combineReducers", () => { interface State1 { foo: number; } interface State2 { bar: string; } class TestReducer1 extends ImmerReducer { setFoo(foo: number) { this.draftState.foo = foo; } } class TestReducer2 extends ImmerReducer { setBar(bar: string) { this.draftState.bar = bar; } } const ActionCreators1 = createActionCreators(TestReducer1); const ActionCreators2 = createActionCreators(TestReducer2); const slice1 = createReducerFunction(TestReducer1, {foo: 0}); const slice2 = createReducerFunction(TestReducer2, {bar: ""}); const combined = combineReducers({slice1, slice2}); const store = createStore(combined); store.dispatch(ActionCreators1.setFoo(1)); store.dispatch(ActionCreators2.setBar("barval")); const state: { slice1: State1; slice2: State2; } = store.getState(); expect(state).toEqual({slice1: {foo: 1}, slice2: {bar: "barval"}}); }); test("cannot collide reducers", () => { const initialState = {foo: "bar"}; class TestReducer1 extends ImmerReducer { setFoo() { this.draftState.foo = "1"; } } class TestReducer2 extends ImmerReducer { setFoo() { this.draftState.foo = "2"; } } const reducer = composeReducers( createReducerFunction(TestReducer1), createReducerFunction(TestReducer2), ); const store = createStore(reducer, initialState); const ActionCreators1 = createActionCreators(TestReducer1); const ActionCreators2 = createActionCreators(TestReducer2); store.dispatch(ActionCreators1.setFoo()); expect(store.getState()).toEqual({foo: "1"}); store.dispatch(ActionCreators2.setFoo()); expect(store.getState()).toEqual({foo: "2"}); }); test("dynamically generated reducers do not collide", () => { const initialState = { foo: "", }; function createGenericReducer( value: string, ) { return class GenericReducer extends ImmerReducer { set() { Object.assign(this.draftState, {foo: value}); } }; } const ReducerClass1 = createGenericReducer("1"); const ReducerClass2 = createGenericReducer("2"); const reducer1 = createReducerFunction(ReducerClass1, initialState); const reducer2 = createReducerFunction(ReducerClass2, initialState); const reducer = composeReducers(reducer1, reducer2); const ActionCreators1 = createActionCreators(ReducerClass1); const ActionCreators2 = createActionCreators(ReducerClass2); const store = createStore(reducer); store.dispatch(ActionCreators1.set()); expect(store.getState().foo).toEqual("1"); store.dispatch(ActionCreators2.set()); expect(store.getState().foo).toEqual("2"); }); test("can create dynamic reducers after creating actions", () => { const initialState = { foo: "", }; function createGenericReducer( value: string, ) { return class GenericReducer extends ImmerReducer { set() { Object.assign(this.draftState, {foo: value}); } }; } const ReducerClass1 = createGenericReducer("1"); const ReducerClass2 = createGenericReducer("2"); const ActionCreators1 = createActionCreators(ReducerClass1); const ActionCreators2 = createActionCreators(ReducerClass2); const reducer1 = createReducerFunction(ReducerClass1, initialState); const reducer2 = createReducerFunction(ReducerClass2, initialState); const reducer = composeReducers(reducer1, reducer2); const store = createStore(reducer); store.dispatch(ActionCreators1.set()); expect(store.getState().foo).toEqual("1"); store.dispatch(ActionCreators2.set()); expect(store.getState().foo).toEqual("2"); }); test("throw error when using duplicate customNames", () => { class Reducer1 extends ImmerReducer<{foo: string}> { static customName = "dup"; set() { this.draftState.foo = "foo"; } } class Reducer2 extends ImmerReducer<{foo: string}> { static customName = "dup"; set() { this.draftState.foo = "foo"; } } createReducerFunction(Reducer1); expect(() => { createReducerFunction(Reducer2); }).toThrow(); }); test("action creators expose the actual action type name", () => { const initialState = {foo: "bar"}; class TestReducer extends ImmerReducer { setBar(foo: string) { this.draftState.foo = foo; } } const ActionCreators = createActionCreators(TestReducer); expect(ActionCreators.setBar.type).toEqual( "IMMER_REDUCER:TestReducer#setBar", ); }); test("can customize prefix of action type name what is returned by action creator.", () => { const initialState = {foo: "bar"}; class TestReducer extends ImmerReducer { setBar(foo: string) { this.draftState.foo = foo; } } setPrefix("AWESOME_LIBRARY"); const ActionCreators = createActionCreators(TestReducer); expect(ActionCreators.setBar.type).toEqual( "AWESOME_LIBRARY:TestReducer#setBar", ); const reducer = createReducerFunction(TestReducer); const store = createStore(reducer, initialState); store.dispatch(ActionCreators.setBar("ding")); expect(store.getState()).toEqual({foo: "ding"}); }); test("isActionFrom can detect actions", () => { class TestReducer extends ImmerReducer<{foo: string}> { setFoo(foo: string) { this.draftState.foo = foo; } } const ActionCreators = createActionCreators(TestReducer); const action1: Action = ActionCreators.setFoo("foo"); const action2: Action = { type: "other", }; expect(isActionFrom(action1, TestReducer)).toBe(true); expect(isActionFrom(action2, TestReducer)).toBe(false); }); test("isAction can detect actions", () => { class TestReducer extends ImmerReducer<{foo: string}> { setFoo(foo: string) { this.draftState.foo = foo; } } const ActionCreators = createActionCreators(TestReducer); const action1: Action = ActionCreators.setFoo("foo"); const action2: Action = { type: "other", }; expect(isAction(action1, ActionCreators.setFoo)).toBe(true); expect(isAction(action2, ActionCreators.setFoo)).toBe(false); }); test("single argument is the payload value", () => { class TestReducer extends ImmerReducer<{}> { singleArg(arg: string) {} } const action = createActionCreators(TestReducer).singleArg("foo"); expect(action.payload).toEqual("foo"); }); test("multiple arguments are as an array in the payload", () => { class TestReducer extends ImmerReducer<{}> { multiple(arg1: string, arg2: number) {} } const action = createActionCreators(TestReducer).multiple("foo", 2); expect(action.payload).toEqual(["foo", 2]); }); test("single argument can be an array", () => { class TestReducer extends ImmerReducer<{}> { singleArg(arg: string[]) {} } const action = createActionCreators(TestReducer).singleArg(["foo"]); expect(action.payload).toEqual(["foo"]); }); test("single array argument is dispatched correctly", () => { expect.assertions(1); class TestReducer extends ImmerReducer<{}> { arrayArg(arr: string[]) { expect(arr).toEqual(["foo", "bar"]); } } const store = createStore(createReducerFunction(TestReducer, {})); store.dispatch(createActionCreators(TestReducer).arrayArg(["foo", "bar"])); }); test("puts only defined arguments to the action object", () => { class TestReducer extends ImmerReducer<{}> { doIt() {} } // Simulate click handler type let onClick = (arg: string): any => {}; // "Pass action the event handler" onClick = createActionCreators(TestReducer).doIt; const action = onClick("nope"); expect(action.payload).toEqual([]); }); test("puts only defined arguments to the action object", () => { class TestReducer extends ImmerReducer<{}> { doIt(oneArg: string) {} } // Simulate click handler type let onClick = (first: string, second: string): any => {}; // "Pass action the event handler" onClick = createActionCreators(TestReducer).doIt; const action = onClick("yes", "nope"); expect(action.payload).toEqual("yes"); }); test("can replace the draft state with completely new state", () => { const initialState = {foo: "bar", ding: "ding"}; class TestReducer extends ImmerReducer { resetState() { this.draftState = { foo: "new", ding: "new", }; } } const ActionCreators = createActionCreators(TestReducer); const reducer = createReducerFunction(TestReducer); const store = createStore(reducer, initialState); store.dispatch(ActionCreators.resetState()); expect(store.getState()).toEqual({ foo: "new", ding: "new", }); }); ================================================ FILE: __tests__/use-reducer-integration.test.tsx ================================================ import React from "react"; import {render, fireEvent, cleanup} from "@testing-library/react"; import { ImmerReducer, createActionCreators, createReducerFunction, } from "../src/immer-reducer"; afterEach(cleanup); test("can use with React.useReducer()", () => { const initialState = {foo: ""}; class Reducer extends ImmerReducer { setFoo(foo: string) { this.draftState.foo = foo; } } const ActionCreators = createActionCreators(Reducer); const reducerFuntion = createReducerFunction(Reducer); function Foo() { const [state, dispatch] = React.useReducer( reducerFuntion, initialState, ); return ( ); } const rtl = render(); const button = rtl.getByTestId("button"); fireEvent.click(button); expect(button.innerHTML).toBe("clicked"); }); ================================================ FILE: jest.config.js ================================================ module.exports = { moduleFileExtensions: ["ts", "tsx", "js"], transform: { "^.+\\.(ts|tsx)$": "ts-jest", }, globals: { "ts-jest": { tsconfig: "tsconfig.json", }, }, testMatch: ["**/?(*.)+(spec|test).ts?(x)"], }; ================================================ FILE: package.json ================================================ { "name": "immer-reducer", "version": "0.7.13", "description": "", "main": "lib/immer-reducer.js", "types": "lib/immer-reducer.d.ts", "repository": { "url": "https://github.com/epeli/immer-reducer" }, "scripts": { "test": "npm run dtslint && jest", "build": "tsc --project tsconfig.build.json && rm -rf lib && mv build/src lib && rm -rf build", "clean": "rm -rf lib build", "dtslint": "tslint --project tsconfig.dtslint.json", "prepublishOnly": "npm run test && npm run build" }, "keywords": [ "typescript", "immer" ], "author": "", "license": "MIT", "files": [ "lib" ], "devDependencies": { "@testing-library/react": "^8.0.4", "@types/jest": "^24.0.15", "@types/react": "^16.8.22", "@types/react-dom": "^16.8.4", "@types/redux": "^3.6.0", "dtslint": "^4.0.7", "jest": "^26.6.3", "prettier": "^1.18.2", "react": "^16.8.6", "react-dom": "^16.8.6", "redux": "^4.0.1", "ts-jest": "^26.5.1", "typescript": "^3.9.9" }, "dependencies": { "immer": "^1.4.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^8.0.0 || ^9.0.0" } } ================================================ FILE: src/immer-reducer.ts ================================================ import produce, {Draft} from "immer"; let actionTypePrefix = "IMMER_REDUCER"; /** get function arguments as tuple type */ type ArgumentsType = T extends (...args: infer V) => any ? V : never; /** * Get the first value of tuple when the tuple length is 1 otherwise return the * whole tuple */ type FirstOrAll = T extends [infer V] ? V : T; /** Get union of function property names */ type FunctionPropertyNames = { [K in keyof T]: T[K] extends Function ? K : never; }[keyof T]; type MethodObject = {[key: string]: () => any}; /** Pick only methods from object */ type Methods = Pick>; /** flatten functions in an object to their return values */ type FlattenToReturnTypes = { [K in keyof T]: ReturnType; }; /** get union of object value types */ type ObjectValueTypes = T[keyof T]; /** get union of object method return types */ type ReturnTypeUnion = ObjectValueTypes< FlattenToReturnTypes >; /** * Get union of actions types from a ImmerReducer class */ export type Actions = ReturnTypeUnion< ActionCreators >; /** type constraint for the ImmerReducer class */ export interface ImmerReducerClass { customName?: string; new (...args: any[]): ImmerReducer; } /** get state type from a ImmerReducer subclass */ export type ImmerReducerState = T extends { prototype: { state: infer V; }; } ? V : never; /** generate reducer function type from the ImmerReducer class */ export interface ImmerReducerFunction { ( state: ImmerReducerState | undefined, action: ReturnTypeUnion>, ): ImmerReducerState; } /** ActionCreator function interface with actual action type name */ interface ImmerActionCreator { readonly type: ActionTypeType; (...args: Payload): { type: ActionTypeType; payload: FirstOrAll; }; } /** generate ActionCreators types from the ImmerReducer class */ export type ActionCreators = { [K in keyof Methods>]: ImmerActionCreator< K, ArgumentsType[K]> >; }; /** * Internal type for the action */ type ImmerAction = | { type: string; payload: unknown; args?: false; } | { type: string; payload: unknown[]; args: true; }; /** * Type guard for detecting actions created by immer reducer * * @param action any redux action * @param immerActionCreator method from a ImmerReducer class */ export function isAction>( action: {type: any}, immerActionCreator: A, ): action is ReturnType { return action.type === immerActionCreator.type; } function isActionFromClass( action: {type: any}, immerReducerClass: T, ): action is Actions { if (typeof action.type !== "string") { return false; } if (!action.type.startsWith(actionTypePrefix + ":")) { return false; } const [className, methodName] = removePrefix(action.type).split("#"); if (className !== getReducerName(immerReducerClass)) { return false; } if (typeof immerReducerClass.prototype[methodName] !== "function") { return false; } return true; } export function isActionFrom( action: {type: any}, immerReducerClass: T, ): action is Actions { return isActionFromClass(action, immerReducerClass); } interface Reducer { (state: State | undefined, action: any): State; } /** * Combine multiple reducers into a single one * * @param reducers two or more reducer */ export function composeReducers( ...reducers: Reducer[] ): Reducer { return (state: any, action: any) => { return ( reducers.reduce((state, subReducer) => { if (typeof subReducer === "function") { return subReducer(state, action); } return state; }, state) || state ); }; } /** The actual ImmerReducer class */ export class ImmerReducer { static customName?: string; readonly state: T; draftState: Draft; // Make read only states mutable using Draft constructor(draftState: Draft, state: T) { this.state = state; this.draftState = draftState; } } function removePrefix(actionType: string) { return actionType .split(":") .slice(1) .join(":"); } let KNOWN_REDUCER_CLASSES: typeof ImmerReducer[] = []; const DUPLICATE_INCREMENTS: {[name: string]: number | undefined} = {}; /** * Set customName for classes automatically if there is multiple reducers * classes defined with the same name. This can occur accidentaly when using * name mangling with minifiers. * * @param immerReducerClass */ function setCustomNameForDuplicates(immerReducerClass: typeof ImmerReducer) { const hasSetCustomName = KNOWN_REDUCER_CLASSES.find(klass => Boolean(klass === immerReducerClass), ); if (hasSetCustomName) { return; } const duplicateCustomName = immerReducerClass.customName && KNOWN_REDUCER_CLASSES.find(klass => Boolean( klass.customName && klass.customName === immerReducerClass.customName, ), ); if (duplicateCustomName) { throw new Error( `There is already customName ${immerReducerClass.customName} defined for ${duplicateCustomName.name}`, ); } const duplicate = KNOWN_REDUCER_CLASSES.find( klass => klass.name === immerReducerClass.name, ); if (duplicate && !duplicate.customName) { let number = DUPLICATE_INCREMENTS[immerReducerClass.name]; if (number) { number++; } else { number = 1; } DUPLICATE_INCREMENTS[immerReducerClass.name] = number; immerReducerClass.customName = immerReducerClass.name + "_" + number; } KNOWN_REDUCER_CLASSES.push(immerReducerClass); } /** * Convert function arguments to ImmerAction object */ function createImmerAction(type: string, args: unknown[]): ImmerAction { if (args.length === 1) { return {type, payload: args[0]}; } return { type, payload: args, args: true, }; } /** * Get function arguments from the ImmerAction object */ function getArgsFromImmerAction(action: ImmerAction): unknown[] { if (action.args) { return action.payload; } return [action.payload]; } function getAllPropertyNames(obj: object) { const proto = Object.getPrototypeOf(obj); const inherited: string[] = proto ? getAllPropertyNames(proto) : []; return Object.getOwnPropertyNames(obj) .concat(inherited) .filter( (propertyName, index, uniqueList) => uniqueList.indexOf(propertyName) === index, ); } export function createActionCreators( immerReducerClass: T, ): ActionCreators { setCustomNameForDuplicates(immerReducerClass); const actionCreators: {[key: string]: Function} = {}; const immerReducerProperties = getAllPropertyNames(ImmerReducer.prototype); getAllPropertyNames(immerReducerClass.prototype).forEach(key => { if (immerReducerProperties.includes(key)) { return; } const method = immerReducerClass.prototype[key]; if (typeof method !== "function") { return; } const type = `${actionTypePrefix}:${getReducerName( immerReducerClass, )}#${key}`; const actionCreator = (...args: any[]) => { // Make sure only the arguments are passed to the action object that // are defined in the method return createImmerAction(type, args.slice(0, method.length)); }; actionCreator.type = type; actionCreators[key] = actionCreator; }); return actionCreators as any; // typed in the function signature } function getReducerName(klass: {name: string; customName?: string}) { const name = klass.customName || klass.name; if (!name) { throw new Error( `immer-reducer failed to get reducer name for a class. Try adding 'static customName = "name"'`, ); } return name; } export function createReducerFunction( immerReducerClass: T, initialState?: ImmerReducerState, ): ImmerReducerFunction { setCustomNameForDuplicates(immerReducerClass); return function immerReducerFunction(state, action) { if (state === undefined) { state = initialState; } if (!isActionFromClass(action, immerReducerClass)) { return state; } if (!state) { throw new Error( "ImmerReducer does not support undefined state. Pass initial state to createReducerFunction() or createStore()", ); } const [_, methodName] = removePrefix(action.type as string).split("#"); return produce(state, draftState => { const reducers: any = new immerReducerClass(draftState, state); reducers[methodName](...getArgsFromImmerAction(action as any)); // The reducer replaced the instance with completely new state so // make that to be the next state if (reducers.draftState !== draftState) { return reducers.draftState; } return draftState; // Workaround typing changes in Immer 9.x. This does not actually // affect the exposed types by immer-reducer itself. // Also using immer internally with anys like this allow us to // support multiple versions of immer. }) as any; }; } export function setPrefix(prefix: string): void { actionTypePrefix = prefix; } /** * INTERNAL! This is only for tests! */ export function _clearKnownClasses() { KNOWN_REDUCER_CLASSES = []; } /** * https://webpack.js.org/api/hot-module-replacement/#module-api */ interface WebpackModule { hot?: { status(): string; addStatusHandler?: (handler: (status: string) => void) => void; }; } /** * Webpack Module global if using Wepback */ declare const module: WebpackModule | undefined; if (typeof module !== "undefined") { // Clear classes on Webpack Hot Module replacement as it will mess up the // duplicate checks appear module.hot?.addStatusHandler?.(status => { if (status === "prepare") { _clearKnownClasses(); } }); } ================================================ FILE: tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "exclude": ["__dtslint__"], "compilerOptions": { "sourceMap": true, "noEmit": false, "outDir": "./build", "declaration": true, "declarationDir": "./build" } } ================================================ FILE: tsconfig.dtslint.json ================================================ { "extends": "./tsconfig.json", "exclude": [] } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "module": "commonjs", "target": "es5", "noEmit": true, "jsx": "react", "lib": ["esnext", "dom"], "moduleResolution": "node", "forceConsistentCasingInFileNames": true, "strict": true, "esModuleInterop": true } } ================================================ FILE: tslint.json ================================================ { "rulesDirectory": "./node_modules/dtslint/bin/rules", "rules": { "expect": true } }