UNPKG

10.3 kBMarkdownView Raw
1# immer-reducer
2
3Type-safe and terse reducers with Typescript for React Hooks and Redux using [Immer](https://immerjs.github.io/immer/)!
4
5## 📦 Install
6
7 npm install immer-reducer
8
9You can also install [eslint-plugin-immer-reducer](https://github.com/skoshy/eslint-plugin-immer-reducer) to help you avoid errors when writing your reducer.
10
11## 💪 Motivation
12
13Turn this 💩 💩 💩
14
15```ts
16interface SetFirstNameAction {
17 type: "SET_FIRST_NAME";
18 firstName: string;
19}
20
21interface SetLastNameAction {
22 type: "SET_LAST_NAME";
23 lastName: string;
24}
25
26type Action = SetFirstNameAction | SetLastNameAction;
27
28function reducer(action: Action, state: State): State {
29 switch (action.type) {
30 case "SET_FIRST_NAME":
31 return {
32 ...state,
33 user: {
34 ...state.user,
35 firstName: action.firstName,
36 },
37 };
38 case "SET_LAST_NAME":
39 return {
40 ...state,
41 user: {
42 ...state.user,
43 lastName: action.lastName,
44 },
45 };
46 default:
47 return state;
48 }
49}
50```
51
52✨✨ Into this! ✨✨
53
54```ts
55import {ImmerReducer} from "immer-reducer";
56
57class MyImmerReducer extends ImmerReducer<State> {
58 setFirstName(firstName: string) {
59 this.draftState.user.firstName = firstName;
60 }
61
62 setLastName(lastName: string) {
63 this.draftState.user.lastName = lastName;
64 }
65}
66```
67
68🔥🔥 **Without losing type-safety!** 🔥🔥
69
70Oh, and you get the action creators for free! 🤗 🎂
71
72## 📖 Usage
73
74Generate Action Creators and the actual reducer function for Redux from the class with
75
76```ts
77import {createStore} from "redux";
78import {createActionCreators, createReducerFunction} from "immer-reducer";
79
80const initialState: State = {
81 user: {
82 firstName: "",
83 lastName: "",
84 },
85};
86
87const ActionCreators = createActionCreators(MyImmerReducer);
88const reducerFunction = createReducerFunction(MyImmerReducer, initialState);
89
90const store = createStore(reducerFunction);
91```
92
93Dispatch some actions
94
95```ts
96store.dispatch(ActionCreators.setFirstName("Charlie"));
97store.dispatch(ActionCreators.setLastName("Brown"));
98
99expect(store.getState().user.firstName).toEqual("Charlie");
100expect(store.getState().user.lastName).toEqual("Brown");
101```
102
103## 🌟 Typed Action Creators!
104
105The generated `ActionCreator` object respect the types used in the class
106
107```ts
108const action = ActionCreators.setFirstName("Charlie");
109action.payload; // Has the type of string
110
111ActionCreators.setFirstName(1); // Type error. Needs string.
112ActionCreators.setWAT("Charlie"); // Type error. Unknown method
113```
114
115If the reducer class where to have a method which takes more than one argument
116the payload would be array of the arguments
117
118```ts
119// In the Reducer class:
120// setName(firstName: string, lastName: string) {}
121const action = ActionCreators.setName("Charlie", "Brown");
122action.payload; // will have value ["Charlie", "Brown"] and type [string, string]
123```
124
125The reducer function is also typed properly
126
127```ts
128const reducer = createReducerFunction(MyImmerReducer);
129
130reducer(initialState, ActionCreators.setFirstName("Charlie")); // OK
131reducer(initialState, {type: "WAT"}); // Type error
132reducer({wat: "bad state"}, ActionCreators.setFirstName("Charlie")); // Type error
133```
134
135## ⚓ React Hooks
136
137Because the `useReducer()` API in React Hooks is the same as with Redux
138Reducers immer-reducer can be used with as is.
139
140```tsx
141const initialState = {message: ""};
142
143class ReducerClass extends ImmerReducer<typeof initialState> {
144 setMessage(message: string) {
145 this.draftState.message = message;
146 }
147}
148
149const ActionCreators = createActionCreators(ReducerClass);
150const reducerFunction = createReducerFunction(ReducerClass);
151
152function Hello() {
153 const [state, dispatch] = React.useReducer(reducerFunction, initialState);
154
155 return (
156 <button
157 data-testid="button"
158 onClick={() => {
159 dispatch(ActionCreators.setMessage("Hello!"));
160 }}
161 >
162 {state.message}
163 </button>
164 );
165}
166```
167
168The returned state and dispatch functions will be typed as you would expect.
169
170## 🤔 How
171
172Under the hood the class is deconstructed to following actions:
173
174```js
175{
176 type: "IMMER_REDUCER:MyImmerReducer#setFirstName",
177 payload: "Charlie",
178}
179{
180 type: "IMMER_REDUCER:MyImmerReducer#setLastName",
181 payload: "Brown",
182}
183{
184 type: "IMMER_REDUCER:MyImmerReducer#setName",
185 payload: ["Charlie", "Brown"],
186 args: true
187}
188```
189
190So the class and method names become the Redux Action Types and the method
191arguments become the action payloads. The reducer function will then match
192these actions against the class and calls the appropriate methods with the
193payload array spread to the arguments.
194
195🚫 The format of the `action.type` string is internal to immer-reducer. If
196you need to detect the actions use the provided type guards.
197
198The generated reducer function executes the methods inside the `produce()`
199function of Immer enabling the terse mutatable style updates.
200
201## 🔄 Integrating with the Redux ecosystem
202
203To integrate for example with the side effects libraries such as
204[redux-observable](https://github.com/redux-observable/redux-observable/) and
205[redux-saga](https://github.com/redux-saga/redux-saga), you can access the
206generated action type using the `type` property of the action creator
207function.
208
209With redux-observable
210
211```ts
212// Get the action name to subscribe to
213const setFirstNameActionTypeName = ActionCreators.setFirstName.type;
214
215// Get the action type to have a type safe Epic
216type SetFirstNameAction = ReturnType<typeof ActionCreators.setFirstName>;
217
218const setFirstNameEpic: Epic<SetFirstNameAction> = action$ =>
219 action$
220 .ofType(setFirstNameActionTypeName)
221 .pipe(
222 // action.payload - recognized as string
223 map(action => action.payload.toUpperCase()),
224 ...
225 );
226```
227
228With redux-saga
229
230```ts
231function* watchFirstNameChanges() {
232 yield takeEvery(ActionCreators.setFirstName.type, doStuff);
233}
234
235// or use the isActionFrom() to get all actions from a specific ImmerReducer
236// action creators object
237function* watchImmerActions() {
238 yield takeEvery(
239 (action: Action) => isActionFrom(action, MyImmerReducer),
240 handleImmerReducerAction,
241 );
242}
243
244function* handleImmerReducerAction(action: Actions<typeof MyImmerReducer>) {
245 // `action` is a union of action types
246 if (isAction(action, ActionCreators.setFirstName)) {
247 // with action of setFirstName
248 }
249}
250```
251
252**Warning:** Due to how immer-reducers action generation works, adding default
253parameters to the methods will NOT pass it to the action payload, which can
254make your reducer impure and the values will not be available in middlewares.
255
256```ts
257class MyImmerReducer extends ImmerReducer<State> {
258 addItem (id: string = uuid()) {
259 this.draftState.ids.push([id])
260 }
261}
262
263immerActions.addItem() // generates empty payload { payload: [] }
264```
265
266As a workaround, create custom action creator wrappers that pass the default parameters instead.
267
268```ts
269class MyImmerReducer extends ImmerReducer<State> {
270 addItem (id) {
271 this.draftState.ids.push([id])
272 }
273}
274
275const actions = {
276 addItem: () => immerActions.addItem(id)
277}
278```
279
280It is also recommended to install the ESLint plugin in the "Install" section
281to alert you if you accidentally encounter this issue.
282
283## 📚 Examples
284
285Here's a more complete example with redux-saga and [redux-render-prop](https://github.com/epeli/redux-render-prop):
286
287<https://github.com/epeli/typescript-redux-todoapp>
288
289## 🃏 Tips and Tricks
290
291You 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.
292
293```ts
294import {ImmerReducer} from "immer-reducer";
295
296const initialState: State = {
297 user: {
298 firstName: "",
299 lastName: "",
300 },
301};
302
303class MyImmerReducer extends ImmerReducer<State> {
304 // omitting other reducer methods
305
306 reset() {
307 this.draftState = initialState;
308 }
309}
310```
311
312## 📓 Helpers
313
314The module exports following helpers
315
316### `function isActionFrom(action, ReducerClass)`
317
318Type guard for detecting whether the given action is generated by the given
319reducer class. The detected type will be union of actions the class
320generates.
321
322Example
323
324```ts
325if (isActionFrom(someAction, ActionCreators)) {
326 // someAction now has type of
327 // {
328 // type: "setFirstName";
329 // payload: string;
330 // } | {
331 // type: "setLastName";
332 // payload: string;
333 // };
334}
335```
336
337### `function isAction(action, actionCreator)`
338
339Type guard for detecting specific actions generated by immer-reducer.
340
341Example
342
343```ts
344if (isAction(someAction, ActionCreators.setFirstName)) {
345 someAction.payload; // Type checks to `string`
346}
347```
348
349### `type Actions<ImmerReducerClass>`
350
351Get union of the action types generated by the ImmerReducer class
352
353Example
354
355```ts
356type MyActions = Actions<typeof MyImmerReducer>;
357
358// Is the same as
359type MyActions =
360 | {
361 type: "setFirstName";
362 payload: string;
363 }
364 | {
365 type: "setLastName";
366 payload: string;
367 };
368```
369
370### `function setPrefix(prefix: string)`
371
372The default prefix in the generated action types is `IMMER_REDUCER`. Call
373this customize it for your app.
374
375Example
376
377```ts
378setPrefix("MY_APP");
379```
380
381### `function composeReducers<State>(...reducers)`
382
383Utility that reduces actions by applying them through multiple reducers.
384This helps in allowing you to split up your reducer logic to multiple `ImmerReducer`s
385if they affect the same part of your state
386
387Example
388
389```ts
390class MyNameReducer extends ImmerReducer<NamesState> {
391 setFirstName(firstName: string) {
392 this.draftState.firstName = firstName;
393 }
394
395 setLastName(lastName: string) {
396 this.draftState.lastName = lastName;
397 }
398}
399
400class MyAgeReducer extends ImmerReducer<AgeState> {
401 setAge(age: number) {
402 this.draftState.age = 8;
403 }
404}
405
406export const reducer = composeReducers(
407 createReducerFunction(MyNameReducer, initialState),
408 createReducerFunction(MyAgeReducer, initialState)
409)
410```