├── .gitignore ├── babel.config.js ├── .eslintrc ├── LICENSE ├── package.json ├── README.md ├── src ├── index.ts └── index.test.ts ├── tsconfig.json └── jest.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .rts2_cache_cjs 2 | .rts2_cache_es 3 | .rts2_cache_umd 4 | dist 5 | node_modules 6 | coverage 7 | *.tgz 8 | yarn-error.log 9 | .idea 10 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = api => { 2 | const isTest = api.env("test"); 3 | if (isTest) { 4 | return { 5 | presets: [ 6 | ["@babel/preset-env", { targets: { node: "current" } }], 7 | "@babel/preset-typescript" 8 | ] 9 | }; 10 | } 11 | 12 | return {}; 13 | }; 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint", "react-hooks"], 4 | "rules": { 5 | "react-hooks/rules-of-hooks": "error", 6 | "react-hooks/exhaustive-deps": "warn", 7 | "indent": "off", 8 | "@typescript-eslint/indent": "off", 9 | "@typescript-eslint/no-explicit-any": "off", 10 | "@typescript-eslint/prefer-interface": "off", 11 | "@typescript-eslint/explicit-function-return-type": "off" 12 | }, 13 | "extends": ["plugin:@typescript-eslint/recommended"] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Lenz Weber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-local-slice", 3 | "description": "A react hook to use reducers for local state in a typesafe way, with an API like createSlice from redux-starter-kit and immer integration.", 4 | "version": "1.2.1", 5 | "repository": "https://github.com/phryneas/use-local-slice", 6 | "author": "phryneas", 7 | "license": "MIT", 8 | "private": false, 9 | "sideEffects": false, 10 | "dependencies": { 11 | "immer": ">=1.0.0 <10.0.0" 12 | }, 13 | "peerDependencies": { 14 | "react": ">=16.8.0" 15 | }, 16 | "devDependencies": { 17 | "@babel/preset-env": "^7.12.11", 18 | "@babel/preset-typescript": "^7.12.7", 19 | "@testing-library/react-hooks": "^5.0.2", 20 | "@types/jest": "^26.0.20", 21 | "@types/react": "^17.0.0", 22 | "@typescript-eslint/eslint-plugin": "^4.14.0", 23 | "@typescript-eslint/parser": "^4.14.0", 24 | "eslint": "^7.18.0", 25 | "eslint-plugin-react-hooks": "^4.2.0", 26 | "jest": "^26.6.3", 27 | "microbundle": "0.11", 28 | "prettier": "^2.2.1", 29 | "react": "^17.0.1", 30 | "react-dom": "^17.0.1", 31 | "react-test-renderer": "^17.0.1", 32 | "rimraf": "^3.0.2", 33 | "typescript": "^4.1.3" 34 | }, 35 | "main": "dist/index.js", 36 | "source": "src/index.ts", 37 | "types": "dist/index.d.ts", 38 | "files": [ 39 | "dist/*" 40 | ], 41 | "scripts": { 42 | "build": "rimraf dist/*; microbundle; rimraf dist/*mjs* dist/*umd* dist/*test*", 43 | "dev": "microbundle watch", 44 | "test": "jest" 45 | }, 46 | "keywords": [ 47 | "hook", 48 | "react", 49 | "reducer", 50 | "redux-starter-kit", 51 | "useReducer", 52 | "immer", 53 | "redux" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # use-local-slice 2 | 3 | An opinionated react hook to use reducers for local state 4 | 5 | - in a typesafe way 6 | - with an API like [createSlice](https://redux-starter-kit.js.org/api/createslice) from [redux-starter-kit](https://redux-starter-kit.js.org) 7 | - with [immer](https://github.com/mweststrate/immer) integration 8 | 9 | ## How to use it 10 | 11 | ```typescript 12 | const [state, dispatchAction] = useLocalSlice({ 13 | slice: "my slice", // optional - will be displayed in the debug tools 14 | initialState: { data: "initial text", someOtherValue: 5 }, 15 | reducers: { 16 | concat: (state, action: { payload: string }) => { 17 | // reducers are passed an immer draft of the current state, so you can directly modify values in the draft 18 | state.data += action.payload; 19 | }, 20 | toUpper: state => ({ 21 | // or you return a modified copy yourself 22 | ...state, 23 | data: state.data.toUpperCase() 24 | }) 25 | // more reducers ... 26 | } 27 | }); 28 | ``` 29 | 30 | and in some callback: 31 | 32 | ```typescript 33 | dispatchAction.concat("concatenate me!"); 34 | // or 35 | dispatchAction.toUpper(); 36 | ``` 37 | 38 | use-local-slice provides one dispatchAction method per reducer, and (for typescript users) ensures that these dispatchers are only called with correct payload types. 39 | 40 | ## Edge case uses & good to know stuff 41 | 42 | - reducers can directly reference other local component state & variables without the need for a `dependencies` array. This is normal `useReducer` behaviour. You can read up on this on the overreacted blog: [Why useReducer Is the Cheat Mode of Hooks](https://overreacted.io/a-complete-guide-to-useeffect/#why-usereducer-is-the-cheat-mode-of-hooks) 43 | - you can exchange reducers for others between renders - as long as the keys of the `reducers` property do not change, you will get an identical instance of `dispatchAction`. 44 | - only renaming, adding or removing keys will get you a new `dispatchAction` instance 45 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useReducer, useMemo, useDebugValue } from "react"; 2 | 3 | import produce, { Draft } from "immer"; 4 | 5 | export type PayloadAction

= { 6 | type: string; 7 | payload: P; 8 | }; 9 | 10 | export type PayloadActionDispatch

= void extends P 11 | ? () => void 12 | : (payload: P) => void; 13 | 14 | export type ReducerWithoutPayload = (state: S) => S; 15 | 16 | export type PayloadActionReducer = ( 17 | state: Draft, 18 | action: PayloadAction

19 | ) => void | S | Draft; 20 | 21 | export type ReducerMap = { 22 | [actionType: string]: PayloadActionReducer; 23 | }; 24 | 25 | export type DispatcherMap> = { 26 | [T in keyof Reducers]: Reducers[T] extends ReducerWithoutPayload 27 | ? PayloadActionDispatch 28 | : Reducers[T] extends PayloadActionReducer 29 | ? PayloadActionDispatch

30 | : never; 31 | }; 32 | 33 | export interface UseLocalSliceOptions< 34 | State, 35 | Reducers extends ReducerMap 36 | > { 37 | initialState: State; 38 | reducers: Reducers; 39 | slice?: string; 40 | } 41 | 42 | export function useLocalSlice>({ 43 | initialState, 44 | reducers, 45 | slice = "unnamed", 46 | }: UseLocalSliceOptions): [State, DispatcherMap] { 47 | useDebugValue(slice); 48 | 49 | const reducer = (baseState: State, action: PayloadAction) => 50 | produce(baseState, (draftState) => 51 | reducers[action.type](draftState, action) 52 | ) as State; 53 | 54 | const [state, dispatch] = useReducer(reducer, initialState); 55 | 56 | const actionTypes = Object.keys(reducers); 57 | 58 | const dispatchAction = useMemo(() => { 59 | let map: { 60 | [actionType: string]: PayloadActionDispatch<{}>; 61 | } = {}; 62 | for (const type of actionTypes) { 63 | map[type] = (payload: any) => dispatch({ type, payload }); 64 | } 65 | return map as DispatcherMap; 66 | // eslint-disable-next-line react-hooks/exhaustive-deps 67 | }, [dispatch, JSON.stringify(actionTypes)]); 68 | 69 | return [state, dispatchAction]; 70 | } 71 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 5 | "module": "esnext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | "allowJs": false /* Allow javascript files to be compiled. */, 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | // "outDir": "./", /* Redirect output structure to the directory. */ 15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "incremental": true, /* Enable incremental compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true /* Enable all strict type-checking options. */, 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | 52 | /* Source Map Options */ 53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 57 | 58 | /* Experimental Options */ 59 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 60 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/tmp/jest_rs", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: "coverage" 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: null, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: null, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: null, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: null, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // An array of directory names to be searched recursively up from the requiring module's location 64 | // moduleDirectories: [ 65 | // "node_modules" 66 | // ], 67 | 68 | // An array of file extensions your modules use 69 | // moduleFileExtensions: [ 70 | // "js", 71 | // "json", 72 | // "jsx", 73 | // "ts", 74 | // "tsx", 75 | // "node" 76 | // ], 77 | 78 | // A map from regular expressions to module names that allow to stub out resources with a single module 79 | // moduleNameMapper: {}, 80 | 81 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 82 | // modulePathIgnorePatterns: [], 83 | 84 | // Activates notifications for test results 85 | // notify: false, 86 | 87 | // An enum that specifies notification mode. Requires { notify: true } 88 | // notifyMode: "failure-change", 89 | 90 | // A preset that is used as a base for Jest's configuration 91 | // preset: null, 92 | 93 | // Run tests from one or more projects 94 | // projects: null, 95 | 96 | // Use this configuration option to add custom reporters to Jest 97 | // reporters: undefined, 98 | 99 | // Automatically reset mock state between every test 100 | // resetMocks: false, 101 | 102 | // Reset the module registry before running each individual test 103 | // resetModules: false, 104 | 105 | // A path to a custom resolver 106 | // resolver: null, 107 | 108 | // Automatically restore mock state between every test 109 | // restoreMocks: false, 110 | 111 | // The root directory that Jest should scan for tests and modules within 112 | // rootDir: null, 113 | 114 | // A list of paths to directories that Jest should use to search for files in 115 | // roots: [ 116 | // "" 117 | // ], 118 | 119 | // Allows you to use a custom runner instead of Jest's default test runner 120 | // runner: "jest-runner", 121 | 122 | // The paths to modules that run some code to configure or set up the testing environment before each test 123 | // setupFiles: [], 124 | 125 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 126 | // setupFilesAfterEnv: [], 127 | 128 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 129 | // snapshotSerializers: [], 130 | 131 | // The test environment that will be used for testing 132 | // testEnvironment: "jest-environment-jsdom", 133 | 134 | // Options that will be passed to the testEnvironment 135 | // testEnvironmentOptions: {}, 136 | 137 | // Adds a location field to test results 138 | // testLocationInResults: false, 139 | 140 | // The glob patterns Jest uses to detect test files 141 | // testMatch: [ 142 | // "**/__tests__/**/*.[jt]s?(x)", 143 | // "**/?(*.)+(spec|test).[tj]s?(x)" 144 | // ], 145 | 146 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 147 | // testPathIgnorePatterns: [ 148 | // "/node_modules/" 149 | // ], 150 | 151 | // The regexp pattern or array of patterns that Jest uses to detect test files 152 | // testRegex: [], 153 | 154 | // This option allows the use of a custom results processor 155 | // testResultsProcessor: null, 156 | 157 | // This option allows use of a custom test runner 158 | // testRunner: "jasmine2", 159 | 160 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 161 | // testURL: "http://localhost", 162 | 163 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 164 | // timers: "real", 165 | 166 | // A map from regular expressions to paths to transformers 167 | // transform: null, 168 | 169 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 170 | // transformIgnorePatterns: [ 171 | // "/node_modules/" 172 | // ], 173 | 174 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 175 | // unmockedModulePathPatterns: undefined, 176 | 177 | // Indicates whether each individual test should be reported during the run 178 | // verbose: null, 179 | 180 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 181 | // watchPathIgnorePatterns: [], 182 | 183 | // Whether to use watchman for file crawling 184 | // watchman: true, 185 | }; 186 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from "@testing-library/react-hooks"; 2 | import { useLocalSlice, UseLocalSliceOptions, ReducerMap } from "./"; 3 | 4 | 5 | function renderUseLocalSlice>( 6 | options: UseLocalSliceOptions 7 | ) { 8 | return renderHook((opts: UseLocalSliceOptions) => useLocalSlice(opts), { 9 | initialProps: options 10 | }); 11 | } 12 | 13 | describe("basic behaviour", () => { 14 | test("initial state", () => { 15 | const { result } = renderUseLocalSlice({ 16 | initialState: { value: "test" }, 17 | reducers: {} 18 | }); 19 | 20 | const { 21 | current: [state] 22 | } = result; 23 | expect(state).toEqual({ value: "test" }); 24 | }); 25 | 26 | test("dispatchAction has reducer names as methods", () => { 27 | const { result } = renderUseLocalSlice({ 28 | initialState: { value: "test" }, 29 | reducers: { 30 | a(state) { 31 | return state; 32 | }, 33 | b(state) { 34 | return state; 35 | } 36 | } 37 | }); 38 | 39 | const { 40 | current: [, dispatchAction] 41 | } = result; 42 | expect(dispatchAction).toEqual({ 43 | a: expect.any(Function), 44 | b: expect.any(Function) 45 | }); 46 | }); 47 | 48 | test("reducer without argument", () => { 49 | const { result } = renderUseLocalSlice({ 50 | initialState: 5, 51 | reducers: { 52 | increment(state) { 53 | return state + 1; 54 | } 55 | } 56 | }); 57 | 58 | let [state, dispatchAction] = result.current; 59 | expect(state).toEqual(5); 60 | 61 | act(() => dispatchAction.increment()); 62 | [state, dispatchAction] = result.current; 63 | expect(state).toEqual(6); 64 | }); 65 | 66 | test("reducer with argument", () => { 67 | const { result } = renderUseLocalSlice({ 68 | initialState: 5, 69 | reducers: { 70 | incrementBy(state, action: { payload: number }) { 71 | return state + action.payload; 72 | } 73 | } 74 | }); 75 | 76 | let [state, dispatchAction] = result.current; 77 | expect(state).toEqual(5); 78 | 79 | act(() => dispatchAction.incrementBy(9)); 80 | [state, dispatchAction] = result.current; 81 | expect(state).toEqual(14); 82 | }); 83 | 84 | test("multiple reducers in complex state", () => { 85 | const { result } = renderUseLocalSlice({ 86 | initialState: { 87 | numberProp: 3, 88 | stringProp: "hello" 89 | }, 90 | reducers: { 91 | incrementBy(state, action: { payload: number }) { 92 | return { ...state, numberProp: state.numberProp + action.payload }; 93 | }, 94 | concat(state, action: { payload: string }) { 95 | return { ...state, stringProp: state.stringProp + action.payload }; 96 | }, 97 | clearString(state) { 98 | return { ...state, stringProp: "" }; 99 | } 100 | } 101 | }); 102 | 103 | let [state, dispatchAction] = result.current; 104 | expect(state).toEqual({ 105 | numberProp: 3, 106 | stringProp: "hello" 107 | }); 108 | 109 | act(() => dispatchAction.incrementBy(9)); 110 | [state, dispatchAction] = result.current; 111 | expect(state.numberProp).toEqual(12); 112 | 113 | act(() => dispatchAction.concat(" world")); 114 | [state, dispatchAction] = result.current; 115 | expect(state.stringProp).toEqual("hello world"); 116 | 117 | act(() => dispatchAction.clearString()); 118 | [state, dispatchAction] = result.current; 119 | expect(state.stringProp).toEqual(""); 120 | 121 | act(() => dispatchAction.concat("new value")); 122 | [state, dispatchAction] = result.current; 123 | expect(state.stringProp).toEqual("new value"); 124 | }); 125 | 126 | test("rerender with same reducer names does return same dispatchAction instance", () => { 127 | const { result, rerender } = renderUseLocalSlice({ 128 | initialState: { 129 | numberProp: 3, 130 | stringProp: "hello" 131 | }, 132 | reducers: { 133 | incrementBy(state, action: { payload: number }) { 134 | return { ...state, numberProp: state.numberProp + action.payload }; 135 | }, 136 | concat(state, action: { payload: string }) { 137 | return { ...state, stringProp: state.stringProp + action.payload }; 138 | }, 139 | clearString(state) { 140 | return { ...state, stringProp: "" }; 141 | } 142 | } 143 | }); 144 | 145 | const [, dispatchAction] = result.current; 146 | 147 | rerender({ 148 | initialState: { 149 | numberProp: 3, 150 | stringProp: "hello" 151 | }, 152 | reducers: { 153 | incrementBy(state, action: { payload: number }) { 154 | // definitely different implementation - should not matter 155 | return state; 156 | }, 157 | concat(state, action: { payload: string }) { 158 | return state; 159 | }, 160 | clearString(state) { 161 | return state; 162 | } 163 | } 164 | }); 165 | 166 | const [, newDispatchAction] = result.current; 167 | 168 | expect(dispatchAction).toBe(newDispatchAction); 169 | }); 170 | 171 | test("rerender with different reducer names does return different dispatchAction instance", () => { 172 | const { result, rerender } = renderUseLocalSlice({ 173 | initialState: { 174 | numberProp: 3, 175 | stringProp: "hello" 176 | }, 177 | reducers: { 178 | incrementBy(state, action: { payload: number }) { 179 | return { ...state, numberProp: state.numberProp + action.payload }; 180 | }, 181 | concat(state, action: { payload: string }) { 182 | return { ...state, stringProp: state.stringProp + action.payload }; 183 | }, 184 | clearString(state) { 185 | return { ...state, stringProp: "" }; 186 | } 187 | } 188 | }); 189 | 190 | const [, dispatchAction] = result.current; 191 | 192 | type S = { 193 | numberProp: number; 194 | stringProp: string; 195 | }; 196 | 197 | rerender({ 198 | initialState: { 199 | numberProp: 3, 200 | stringProp: "hello" 201 | }, 202 | reducers: { 203 | incrementBy(state: S, action: { payload: number }) { 204 | return { ...state, numberProp: state.numberProp + action.payload }; 205 | }, 206 | clearString(state: S) { 207 | return { ...state, stringProp: "" }; 208 | }, 209 | different(state: S) { 210 | return state; 211 | } 212 | } 213 | } as any); 214 | 215 | const [, newDispatchAction] = result.current; 216 | 217 | expect(dispatchAction).not.toBe(newDispatchAction); 218 | expect(Object.keys(dispatchAction)).toEqual([ 219 | "incrementBy", 220 | "concat", 221 | "clearString" 222 | ]); 223 | expect(Object.keys(newDispatchAction)).toEqual([ 224 | "incrementBy", 225 | "clearString", 226 | "different" 227 | ]); 228 | }); 229 | 230 | test("rerender with additional reducer names does return different dispatchAction instance", () => { 231 | const { result, rerender } = renderUseLocalSlice({ 232 | initialState: { 233 | numberProp: 3, 234 | stringProp: "hello" 235 | }, 236 | reducers: { 237 | incrementBy(state, action: { payload: number }) { 238 | return { ...state, numberProp: state.numberProp + action.payload }; 239 | }, 240 | concat(state, action: { payload: string }) { 241 | return { ...state, stringProp: state.stringProp + action.payload }; 242 | }, 243 | clearString(state) { 244 | return { ...state, stringProp: "" }; 245 | } 246 | } 247 | }); 248 | 249 | const [, dispatchAction] = result.current; 250 | 251 | type S = { 252 | numberProp: number; 253 | stringProp: string; 254 | }; 255 | 256 | rerender({ 257 | initialState: { 258 | numberProp: 3, 259 | stringProp: "hello" 260 | }, 261 | reducers: { 262 | incrementBy(state: S, action: { payload: number }) { 263 | return { ...state, numberProp: state.numberProp + action.payload }; 264 | }, 265 | concat(state: S, action: { payload: string }) { 266 | return { ...state, stringProp: state.stringProp + action.payload }; 267 | }, 268 | clearString(state: S) { 269 | return { ...state, stringProp: "" }; 270 | }, 271 | additional(state: S) { 272 | return state; 273 | } 274 | } 275 | } as any); 276 | 277 | const [, newDispatchAction] = result.current; 278 | 279 | expect(dispatchAction).not.toBe(newDispatchAction); 280 | 281 | expect(Object.keys(dispatchAction)).toEqual([ 282 | "incrementBy", 283 | "concat", 284 | "clearString" 285 | ]); 286 | expect(Object.keys(newDispatchAction)).toEqual([ 287 | "incrementBy", 288 | "concat", 289 | "clearString", 290 | "additional" 291 | ]); 292 | }); 293 | 294 | test("reducer implementations can be exchanged for others between renders", () => { 295 | const { result, rerender } = renderUseLocalSlice({ 296 | initialState: 5, 297 | reducers: { 298 | increment(state) { 299 | return state + 1; 300 | } 301 | } 302 | }); 303 | 304 | let [state, dispatchAction] = result.current; 305 | expect(state).toBe(5); 306 | 307 | act(() => dispatchAction.increment()); 308 | [state] = result.current; 309 | expect(state).toBe(6); 310 | 311 | rerender({ 312 | initialState: 5, 313 | reducers: { 314 | increment(state) { 315 | return state + 10; 316 | } 317 | } 318 | }); 319 | 320 | let [, newDispatchAction] = result.current; 321 | expect(dispatchAction).toBe(newDispatchAction); 322 | 323 | act(() => dispatchAction.increment()); 324 | [state] = result.current; 325 | expect(state).toBe(16); 326 | }); 327 | }); 328 | 329 | describe("immer integration in reducers", () => { 330 | test("modification of draft", () => { 331 | const { result } = renderUseLocalSlice({ 332 | initialState: { 333 | stringProp: "hello" 334 | }, 335 | reducers: { 336 | concat(state, action: { payload: string }) { 337 | state.stringProp += action.payload; 338 | } 339 | } 340 | }); 341 | 342 | let [state, dispatchAction] = result.current; 343 | expect(state).toEqual({ stringProp: "hello" }); 344 | 345 | act(() => dispatchAction.concat(" world")); 346 | let [newState] = result.current; 347 | expect(newState).toEqual({ stringProp: "hello world" }); 348 | expect(newState).not.toBe(state); 349 | }); 350 | 351 | test("returning of modified draft", () => { 352 | const { result } = renderUseLocalSlice({ 353 | initialState: { 354 | stringProp: "hello" 355 | }, 356 | reducers: { 357 | concat(state, action: { payload: string }) { 358 | state.stringProp += action.payload; 359 | return state; 360 | } 361 | } 362 | }); 363 | 364 | let [state, dispatchAction] = result.current; 365 | expect(state).toEqual({ stringProp: "hello" }); 366 | 367 | act(() => dispatchAction.concat(" world")); 368 | let [newState] = result.current; 369 | expect(newState).toEqual({ stringProp: "hello world" }); 370 | expect(newState).not.toBe(state); 371 | }); 372 | 373 | test("return something different", () => { 374 | const { result } = renderUseLocalSlice({ 375 | initialState: { 376 | stringProp: "hello" 377 | }, 378 | reducers: { 379 | concat(state, action: { payload: string }) { 380 | return { stringProp: state.stringProp + action.payload }; 381 | } 382 | } 383 | }); 384 | 385 | let [state, dispatchAction] = result.current; 386 | expect(state).toEqual({ stringProp: "hello" }); 387 | 388 | act(() => dispatchAction.concat(" world")); 389 | let [newState] = result.current; 390 | expect(newState).toEqual({ stringProp: "hello world" }); 391 | expect(newState).not.toBe(state); 392 | }); 393 | }); 394 | --------------------------------------------------------------------------------