├── .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
30 | : never;
31 | };
32 |
33 | export interface UseLocalSliceOptions<
34 | State,
35 | Reducers extends ReducerMap;
20 |
21 | export type ReducerMap>(
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 |
--------------------------------------------------------------------------------