1 | # immer-reducer
|
2 |
|
3 | Type-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 |
|
9 | 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.
|
10 |
|
11 | ## 💪 Motivation
|
12 |
|
13 | Turn this 💩 💩 💩
|
14 |
|
15 | ```ts
|
16 | interface SetFirstNameAction {
|
17 | type: "SET_FIRST_NAME";
|
18 | firstName: string;
|
19 | }
|
20 |
|
21 | interface SetLastNameAction {
|
22 | type: "SET_LAST_NAME";
|
23 | lastName: string;
|
24 | }
|
25 |
|
26 | type Action = SetFirstNameAction | SetLastNameAction;
|
27 |
|
28 | function 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
|
55 | import {ImmerReducer} from "immer-reducer";
|
56 |
|
57 | class 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 |
|
70 | Oh, and you get the action creators for free! 🤗 🎂
|
71 |
|
72 | ## 📖 Usage
|
73 |
|
74 | Generate Action Creators and the actual reducer function for Redux from the class with
|
75 |
|
76 | ```ts
|
77 | import {createStore} from "redux";
|
78 | import {createActionCreators, createReducerFunction} from "immer-reducer";
|
79 |
|
80 | const initialState: State = {
|
81 | user: {
|
82 | firstName: "",
|
83 | lastName: "",
|
84 | },
|
85 | };
|
86 |
|
87 | const ActionCreators = createActionCreators(MyImmerReducer);
|
88 | const reducerFunction = createReducerFunction(MyImmerReducer, initialState);
|
89 |
|
90 | const store = createStore(reducerFunction);
|
91 | ```
|
92 |
|
93 | Dispatch some actions
|
94 |
|
95 | ```ts
|
96 | store.dispatch(ActionCreators.setFirstName("Charlie"));
|
97 | store.dispatch(ActionCreators.setLastName("Brown"));
|
98 |
|
99 | expect(store.getState().user.firstName).toEqual("Charlie");
|
100 | expect(store.getState().user.lastName).toEqual("Brown");
|
101 | ```
|
102 |
|
103 | ## 🌟 Typed Action Creators!
|
104 |
|
105 | The generated `ActionCreator` object respect the types used in the class
|
106 |
|
107 | ```ts
|
108 | const action = ActionCreators.setFirstName("Charlie");
|
109 | action.payload; // Has the type of string
|
110 |
|
111 | ActionCreators.setFirstName(1); // Type error. Needs string.
|
112 | ActionCreators.setWAT("Charlie"); // Type error. Unknown method
|
113 | ```
|
114 |
|
115 | If the reducer class where to have a method which takes more than one argument
|
116 | the payload would be array of the arguments
|
117 |
|
118 | ```ts
|
119 | // In the Reducer class:
|
120 | // setName(firstName: string, lastName: string) {}
|
121 | const action = ActionCreators.setName("Charlie", "Brown");
|
122 | action.payload; // will have value ["Charlie", "Brown"] and type [string, string]
|
123 | ```
|
124 |
|
125 | The reducer function is also typed properly
|
126 |
|
127 | ```ts
|
128 | const reducer = createReducerFunction(MyImmerReducer);
|
129 |
|
130 | reducer(initialState, ActionCreators.setFirstName("Charlie")); // OK
|
131 | reducer(initialState, {type: "WAT"}); // Type error
|
132 | reducer({wat: "bad state"}, ActionCreators.setFirstName("Charlie")); // Type error
|
133 | ```
|
134 |
|
135 | ## ⚓ React Hooks
|
136 |
|
137 | Because the `useReducer()` API in React Hooks is the same as with Redux
|
138 | Reducers immer-reducer can be used with as is.
|
139 |
|
140 | ```tsx
|
141 | const initialState = {message: ""};
|
142 |
|
143 | class ReducerClass extends ImmerReducer<typeof initialState> {
|
144 | setMessage(message: string) {
|
145 | this.draftState.message = message;
|
146 | }
|
147 | }
|
148 |
|
149 | const ActionCreators = createActionCreators(ReducerClass);
|
150 | const reducerFunction = createReducerFunction(ReducerClass);
|
151 |
|
152 | function 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 |
|
168 | The returned state and dispatch functions will be typed as you would expect.
|
169 |
|
170 | ## 🤔 How
|
171 |
|
172 | Under 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 |
|
190 | So the class and method names become the Redux Action Types and the method
|
191 | arguments become the action payloads. The reducer function will then match
|
192 | these actions against the class and calls the appropriate methods with the
|
193 | payload array spread to the arguments.
|
194 |
|
195 | 🚫 The format of the `action.type` string is internal to immer-reducer. If
|
196 | you need to detect the actions use the provided type guards.
|
197 |
|
198 | The generated reducer function executes the methods inside the `produce()`
|
199 | function of Immer enabling the terse mutatable style updates.
|
200 |
|
201 | ## 🔄 Integrating with the Redux ecosystem
|
202 |
|
203 | To 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
|
206 | generated action type using the `type` property of the action creator
|
207 | function.
|
208 |
|
209 | With redux-observable
|
210 |
|
211 | ```ts
|
212 | // Get the action name to subscribe to
|
213 | const setFirstNameActionTypeName = ActionCreators.setFirstName.type;
|
214 |
|
215 | // Get the action type to have a type safe Epic
|
216 | type SetFirstNameAction = ReturnType<typeof ActionCreators.setFirstName>;
|
217 |
|
218 | const 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 |
|
228 | With redux-saga
|
229 |
|
230 | ```ts
|
231 | function* 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
|
237 | function* watchImmerActions() {
|
238 | yield takeEvery(
|
239 | (action: Action) => isActionFrom(action, MyImmerReducer),
|
240 | handleImmerReducerAction,
|
241 | );
|
242 | }
|
243 |
|
244 | function* 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
|
253 | parameters to the methods will NOT pass it to the action payload, which can
|
254 | make your reducer impure and the values will not be available in middlewares.
|
255 |
|
256 | ```ts
|
257 | class MyImmerReducer extends ImmerReducer<State> {
|
258 | addItem (id: string = uuid()) {
|
259 | this.draftState.ids.push([id])
|
260 | }
|
261 | }
|
262 |
|
263 | immerActions.addItem() // generates empty payload { payload: [] }
|
264 | ```
|
265 |
|
266 | As a workaround, create custom action creator wrappers that pass the default parameters instead.
|
267 |
|
268 | ```ts
|
269 | class MyImmerReducer extends ImmerReducer<State> {
|
270 | addItem (id) {
|
271 | this.draftState.ids.push([id])
|
272 | }
|
273 | }
|
274 |
|
275 | const actions = {
|
276 | addItem: () => immerActions.addItem(id)
|
277 | }
|
278 | ```
|
279 |
|
280 | It is also recommended to install the ESLint plugin in the "Install" section
|
281 | to alert you if you accidentally encounter this issue.
|
282 |
|
283 | ## 📚 Examples
|
284 |
|
285 | Here'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 |
|
291 | 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.
|
292 |
|
293 | ```ts
|
294 | import {ImmerReducer} from "immer-reducer";
|
295 |
|
296 | const initialState: State = {
|
297 | user: {
|
298 | firstName: "",
|
299 | lastName: "",
|
300 | },
|
301 | };
|
302 |
|
303 | class MyImmerReducer extends ImmerReducer<State> {
|
304 | // omitting other reducer methods
|
305 |
|
306 | reset() {
|
307 | this.draftState = initialState;
|
308 | }
|
309 | }
|
310 | ```
|
311 |
|
312 | ## 📓 Helpers
|
313 |
|
314 | The module exports following helpers
|
315 |
|
316 | ### `function isActionFrom(action, ReducerClass)`
|
317 |
|
318 | Type guard for detecting whether the given action is generated by the given
|
319 | reducer class. The detected type will be union of actions the class
|
320 | generates.
|
321 |
|
322 | Example
|
323 |
|
324 | ```ts
|
325 | if (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 |
|
339 | Type guard for detecting specific actions generated by immer-reducer.
|
340 |
|
341 | Example
|
342 |
|
343 | ```ts
|
344 | if (isAction(someAction, ActionCreators.setFirstName)) {
|
345 | someAction.payload; // Type checks to `string`
|
346 | }
|
347 | ```
|
348 |
|
349 | ### `type Actions<ImmerReducerClass>`
|
350 |
|
351 | Get union of the action types generated by the ImmerReducer class
|
352 |
|
353 | Example
|
354 |
|
355 | ```ts
|
356 | type MyActions = Actions<typeof MyImmerReducer>;
|
357 |
|
358 | // Is the same as
|
359 | type 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 |
|
372 | The default prefix in the generated action types is `IMMER_REDUCER`. Call
|
373 | this customize it for your app.
|
374 |
|
375 | Example
|
376 |
|
377 | ```ts
|
378 | setPrefix("MY_APP");
|
379 | ```
|
380 |
|
381 | ### `function composeReducers<State>(...reducers)`
|
382 |
|
383 | Utility that reduces actions by applying them through multiple reducers.
|
384 | This helps in allowing you to split up your reducer logic to multiple `ImmerReducer`s
|
385 | if they affect the same part of your state
|
386 |
|
387 | Example
|
388 |
|
389 | ```ts
|
390 | class 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 |
|
400 | class MyAgeReducer extends ImmerReducer<AgeState> {
|
401 | setAge(age: number) {
|
402 | this.draftState.age = 8;
|
403 | }
|
404 | }
|
405 |
|
406 | export const reducer = composeReducers(
|
407 | createReducerFunction(MyNameReducer, initialState),
|
408 | createReducerFunction(MyAgeReducer, initialState)
|
409 | )
|
410 | ```
|