react-elmish 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,661 @@
1
+ # react-elmish
2
+
3
+ ![Build](https://github.com/atheck/react-elmish/actions/workflows/main.yml/badge.svg)
4
+ ![npm](https://img.shields.io/npm/v/react-elmish)
5
+
6
+ This library brings the elmish pattern to react.
7
+
8
+ ## Installation
9
+
10
+ `npm install react-elmish`
11
+
12
+ ## Basic Usage
13
+
14
+ An elmish component basically consists of the following parts:
15
+
16
+ * The **Model** holding the state of the component.
17
+ * The **Props** for the component.
18
+ * The **Init** function to create the initial model based on the props.
19
+ * The **Messages** to dispatch which modify the model.
20
+ * The **Update** function to modify the model based on a specific message.
21
+ * The **View** which renders the component based on the current model.
22
+
23
+ ### App.ts
24
+
25
+ First import everything from `react-elmish` and declare the **Message** discriminated union type:
26
+
27
+ ```ts
28
+ import * as Elm from "react-elmish";
29
+
30
+ export type Message =
31
+ | { name: "Increment" }
32
+ | { name: "Decrement" }
33
+ ;
34
+ ```
35
+
36
+ You can also create some convenience functions to dispatch a message:
37
+
38
+ ```ts
39
+ export const Msg = {
40
+ increment: (): Message => ({ name: "Increment" }),
41
+ decrement: (): Message => ({ name: "Decrement" }),
42
+ };
43
+ ```
44
+
45
+ Now we can create a `cmd` object for our messages type:
46
+
47
+ ```ts
48
+ const cmd = Elm.createCmd<Message>();
49
+ ```
50
+
51
+ Next, declare the model:
52
+
53
+ ```ts
54
+ export type Model = Readonly<{
55
+ value: number,
56
+ }>;
57
+ ```
58
+
59
+ The props are optional:
60
+
61
+ ```ts
62
+ export type Props = {
63
+ initialValue: number,
64
+ };
65
+ ```
66
+
67
+ To create the initial model we need an **init** function:
68
+
69
+ ```ts
70
+ export const init = (props: Props): [Model, Elm.Cmd<Message>] => {
71
+ const model: Model = {
72
+ value: props.initialValue,
73
+ };
74
+
75
+ return [model, cmd.none];
76
+ };
77
+ ```
78
+
79
+ To update the model based on a message we need an **update** function:
80
+
81
+ ```ts
82
+ export const update = (model: Model, msg: Msg, props: Props): Elm.UpdateReturnType<Model, Message> => {
83
+ switch (msg.name) {
84
+ case "Increment":
85
+ return [{ value: model.value + 1 }];
86
+
87
+ case "Decrement":
88
+ return [{ value: model.value - 1 }];
89
+ }
90
+ };
91
+ ```
92
+
93
+ > **Note:** If you are using **typescript** and **typescript-eslint** you should enable the [switch-exhaustive-check](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/switch-exhaustiveness-check.md) rule.
94
+
95
+ ### App.tsx
96
+
97
+ To put all this together and to render our component, we need a React component.
98
+
99
+ This can be a **class component**:
100
+
101
+ ```tsx
102
+ // Import everything from the App.ts
103
+ import * as Shared from "../App";
104
+ // Import the ElmComponent which extends the React.Component
105
+ import { ElmComponent } from "react-elmish";
106
+ // Don't forget to import react
107
+ import React from "react";
108
+
109
+ // Create an elmish class component
110
+ class App extends ElmComponent<Shared.Model, Shared.Message, Shared.Props> {
111
+ // Construct the component with the props and init function
112
+ constructor(props: Shared.Props) {
113
+ super(props, Shared.init, "App");
114
+ }
115
+
116
+ // Assign our update function to the component
117
+ update = Shared.update;
118
+
119
+ render(): React.ReactNode {
120
+ // Access the model
121
+ const { value } = this.model;
122
+
123
+ return (
124
+ <div>
125
+ {/* Display our current value */}
126
+ <p>{value}</p>
127
+
128
+ {/* Dispatch messages */}
129
+ <button onClick={() => this.dispatch(Shared.Msg.increment())}>Increment</button>
130
+ <button onClick={() => this.dispatch(Shared.Msg.decrement())}>Decrement</button>
131
+ </div>
132
+ );
133
+ }
134
+ ```
135
+
136
+ Or it can be a **functional component**:
137
+
138
+ ```tsx
139
+ // Import everything from the App.ts
140
+ import * as Shared from "../App";
141
+ // Import the useElmish hook
142
+ import { useElmish } from "react-elmish";
143
+
144
+ const App = (props: Shared.Props) => {
145
+ // Call the useElmish hook, it returns the current model and the dispatch function
146
+ const [model, dispatch] = useElmish(props, Shared.init, Shared.update, "App");
147
+
148
+ return (
149
+ <div>
150
+ {/* Display our current value */}
151
+ <p>{model.value}</p>
152
+
153
+ {/* dispatch messages */}
154
+ <button onClick={() => dispatch(Shared.Msg.increment())}>Increment</button>
155
+ <button onClick={() => dispatch(Shared.Msg.decrement())}>Decrement</button>
156
+ </div>
157
+ );
158
+ };
159
+ ```
160
+
161
+ You can use these components like any other React component.
162
+
163
+ > **Note**: It is recommended to separate business logic and the view into separate modules. Here we put the `Messages`, `Model`, `Props`, `init`, and `update` functions into **App.ts**. The elmish React Component resides in a **Components** subfolder and is named **App.tsx**.
164
+ >
165
+ > You can even split the contents of the **App.ts** into two files: **Types.ts** (`Message`, `Model`, and `Props`) and **State.ts** (`init` and `update`).
166
+
167
+ ## More on messages
168
+
169
+ ### Message arguments
170
+
171
+ Messages can also have arguments. You can modify the example above and pass an optional step value to the **Increment** message:
172
+
173
+ ```ts
174
+ export type Message =
175
+ | { name: "Increment", step?: number }
176
+ ...
177
+
178
+ export const Msg = {
179
+ increment: (step?: number): Message => ({ name: "Increment", step }),
180
+ ...
181
+ }
182
+ ```
183
+
184
+ Then use this argument in the **update** function:
185
+
186
+ ```ts
187
+ ...
188
+ case "Increment":
189
+ return [{ value: model.value + (msg.step ?? 1)}]
190
+ ...
191
+ ```
192
+
193
+ In the **render** method you can add another button to increment the value by 10:
194
+
195
+ ```tsx
196
+ ...
197
+ <button onClick={() => this.dispatch(Shared.Msg.increment(10))}>Increment by 10</button>
198
+ ...
199
+ ```
200
+
201
+ ### Symbols instead of strings
202
+
203
+ You can also use **Symbols** for the message type instead of strings:
204
+
205
+ 1. Declare a Symbol for the message:
206
+
207
+ ```ts
208
+ const ResetMsg = Symbol("reset");
209
+ ```
210
+
211
+ 1. Use this Symbol as message name:
212
+
213
+ ```ts
214
+ export type Message =
215
+ ...
216
+ | { name: typeof ResetMsg }
217
+ ...
218
+ ```
219
+
220
+ 1. Create the convenient function
221
+
222
+ ```ts
223
+ export const Msg = {
224
+ ...
225
+ reset: (): Message => ({ name: ResetMsg }),
226
+ ...
227
+ }
228
+ ```
229
+
230
+ 1. Handle the new message in the **update** function:
231
+
232
+ ```ts
233
+ ...
234
+ case ResetMsg:
235
+ return [{ value: 0 }];
236
+ ...
237
+ ```
238
+
239
+ ## Setup
240
+
241
+ **react-elmish** works without a setup. But if you want to use logging or some middleware, you can setup **react-elmish** at the start of your program.
242
+
243
+ ```ts
244
+ import * as Elm from "react-elmish";
245
+
246
+ const myLogger = {
247
+ debug(...args: unknown []) {
248
+ console.debug(...args);
249
+ },
250
+ info(...args: unknown []) {
251
+ console.info(...args);
252
+ },
253
+ error(...args: unknown []) {
254
+ console.error(...args);
255
+ },
256
+ }
257
+
258
+ Elm.init({
259
+ logger: myLogger,
260
+ errorMiddleware: error => Toast.error(error.message),
261
+ dispatchMiddleware: msg => console.log(msg),
262
+ });
263
+ ```
264
+
265
+ The error middleware function is called by the `handleError` function (see [Error handling](#error-handling)).
266
+
267
+ The dispatch middleware function is called whenever a Message is dispatched.
268
+
269
+ ## Error handling
270
+
271
+ You can handle errors easily with the following pattern.
272
+
273
+ 1. Add an error message:
274
+
275
+ ```ts
276
+ export type Message =
277
+ | ...
278
+ | { name: "Error", error: Error }
279
+ ;
280
+ ```
281
+
282
+ 1. Optionally add the convenient function to the **Msg** object:
283
+
284
+ ```ts
285
+ export const Msg = {
286
+ ...
287
+ error: (error: Error): Message => ({ name: "Error", error }),
288
+ }
289
+ ```
290
+
291
+ 1. Handle the error message in the **update** function:
292
+
293
+ ```ts
294
+ ...
295
+ case "Error":
296
+ return Elm.handleError(msg.error);
297
+ ...
298
+ ```
299
+
300
+ The **handleError** function then calls your error handling middleware.
301
+
302
+ ## Dispatch commands in the update function
303
+
304
+ In addition to modifying the model, you can dispatch new commands in the **update** function.
305
+
306
+ To do so, you can call one of the functions in the `cmd` object:
307
+
308
+ | Function | Description |
309
+ |---|---|
310
+ | `cmd.none` | Does nothing. Equivalent to omit the second value. |
311
+ | `cmd.ofMsg` | Dispatches a new message. |
312
+ | `cmd.batch` | Aggregates an array of messages. |
313
+ | `cmd.ofFunc.either` | Calls a synchronous function and maps the result into a message. |
314
+ | `cmd.ofFunc.attempt` | Like `either` but ignores the success case. |
315
+ | `cmd.ofFunc.perform` | Like `either` but ignores the error case. |
316
+ | `cmd.ofPromise.either` | Calls an async function and maps the result into a message. |
317
+ | `cmd.ofPromise.attempt` | Like `either` but ignores the success case. |
318
+ | `cmd.ofPromise.perform` | Like `either` but ignores the error case. |
319
+
320
+ ### Dispatch a message
321
+
322
+ Let's assume you have a message to display the description of the last called message:
323
+
324
+ ```ts
325
+ export type Message =
326
+ ...
327
+ | { name: "PrintLastMessage", message: string }
328
+ ...
329
+
330
+ export const Msg = {
331
+ ...
332
+ printLastMessage: (message: string): Message => ({ name: "PrintLastMessage", message }),
333
+ ...
334
+ }
335
+ ```
336
+
337
+ In the **update** function you can dispatch that message like this:
338
+
339
+ ```ts
340
+ case "Increment":
341
+ return [{ value: model.value + 1 }, cmd.ofMsg(Msg.printLastMessage("Incremented by one"))];
342
+ ```
343
+
344
+ This new message will immediately be dispatched after returning from the **update** function.
345
+
346
+ ### Call an async function
347
+
348
+ This way you can also call functions and async operations. For an async function like:
349
+
350
+ ```ts
351
+ const loadSettings = async (arg1: string, arg2: number): Promise<Settings> => {
352
+ const settings = await Storage.loadSettings();
353
+ return settings;
354
+ }
355
+ ```
356
+
357
+ you can define the following messages:
358
+
359
+ ```ts
360
+ export type Messages =
361
+ ...
362
+ | { name: "LoadSettings" },
363
+ | { name: "SettingsLoaded", settings: Settings }
364
+ | { name: "Error", error: Error }
365
+ ...
366
+
367
+ export const Msg = {
368
+ ...
369
+ loadSettings: (): Message => ({ name: "LoadSettings" }),
370
+ settingsLoaded: (settings: Settings): Message => ({ name: "SettingsLoaded", settings }),
371
+ error: (error: Error): Message => ({ name: "Error", error }),
372
+ ...
373
+ };
374
+ ```
375
+
376
+ and handle the messages in the **update** function:
377
+
378
+ ```ts
379
+ ...
380
+ case "LoadSettings":
381
+ // Create a command out of the async function with the provided arguments
382
+ // If loadSettings resolves it dispatches "SettingsLoaded"
383
+ // If it fails it dispatches "Error"
384
+ // The return type of loadSettings must fit Msg.settingsLoaded
385
+ return [{}, cmd.ofPromise.either(loadSettings, Msg.settingsLoaded, Msg.error, "firstArg", 123)];
386
+
387
+ case "SettingsLoaded":
388
+ return [{ settings: msg.settings }];
389
+
390
+ case "Error":
391
+ return Elm.handleError(msg.error);
392
+ ...
393
+ ```
394
+
395
+ ## React life cycle management
396
+
397
+ If you want to use `componentDidMount` or `componentWillUnmount` in a class component, don't forget to call the base class implementation of it as the **ElmComponent** is using them internally.
398
+
399
+ ```ts
400
+ class App extends ElmComponent<Shared.Model, Shared.Message, Shared.Props> {
401
+ ...
402
+ componentDidMount() {
403
+ super.componentDidMount();
404
+
405
+ // your code
406
+ }
407
+
408
+ componentWillUnmount() {
409
+ super.componentWillUnmount();
410
+
411
+ // your code
412
+ }
413
+ ...
414
+ }
415
+ ```
416
+
417
+ In a functional component you can use the **useEffect** hook as normal.
418
+
419
+ ## Composition
420
+
421
+ If you have some business logic that you want to reuse in other components, you can do this by using different sources for messages.
422
+
423
+ Let's say you want to load some settings, you can write a module like this:
424
+
425
+ ```ts LoadSettings.ts
426
+ import * as Elm from "react-elmish";
427
+
428
+ export type Settings = {
429
+ // ...
430
+ };
431
+
432
+ // We use a MsgSource to differentiate between the messages
433
+ type MessageSource = Elm.MsgSource<"LoadSettings">;
434
+
435
+ // Add that MessageSource to all the messages
436
+ export type Message =
437
+ | { name: "LoadSettings" } & MessageSource
438
+ | { name: "SettingsLoaded", settings: Settings } & MessageSource
439
+ | { name: "Error", error: Error } & MessageSource
440
+
441
+ // Do the same for the convenient functions
442
+ const MsgSource: MessageSource = { source: "LoadSettings" };
443
+
444
+ export const Msg = {
445
+ loadSettings: (): Message => ({ name: "LoadSettings", ...MsgSource }),
446
+ settingsLoaded: (settings: Settings): Message => ({ name: "SettingsLoaded", settings, ...MsgSource }),
447
+ error: (error: Error): Message => ({ name: "Error", error, ...MsgSource }),
448
+ };
449
+
450
+ const cmd = Elm.createCmd<Message>();
451
+
452
+ export type Model = Readonly<{
453
+ settings: Nullable<Settings>,
454
+ }>;
455
+
456
+ export const init = (): Model => ({
457
+ settings: null,
458
+ });
459
+
460
+ export const update = (_model: Model, msg: Message): Elm.UpdateReturnType<Model, Message> => {
461
+ switch (msg.name) {
462
+ case "LoadSettings":
463
+ return [{}, cmd.ofPromise.either(loadSettings, Msg.settingsLoaded, Msg.error)];
464
+
465
+ case "SettingsLoaded":
466
+ return [{ settings: msg.settings }];
467
+
468
+ case "Error":
469
+ return Elm.handleError(msg.error);
470
+ }
471
+ };
472
+
473
+ const loadSettings = async (): Promise<Settings> => {
474
+ // Call some service (e.g. database or backend)
475
+ return Promise.resolve({});
476
+ };
477
+ ```
478
+
479
+ > **Note**: This module has no **View**.
480
+
481
+ In other components where we want to use this **LoadSettings** module, we also need a message source:
482
+
483
+ ```ts Composition.ts
484
+ import * as Elm from "react-elmish";
485
+ // Import the LoadSettings module
486
+ import * as LoadSettings from "./LoadSettings";
487
+
488
+ // Create a message source for this module
489
+ type MessageSource = Elm.MsgSource<"Composition">;
490
+
491
+ // Here we define our local messages
492
+ // We don't need to export them
493
+ type CompositionMessage =
494
+ | { name: "MyMessage" } & MessageSource
495
+ ;
496
+
497
+ // Combine the local messages and the ones from LoadSettings
498
+ export type Message =
499
+ | CompositionMessage
500
+ | LoadSettings.Message
501
+ ;
502
+
503
+ const MsgSource: MessageSource = { source: "Composition" };
504
+
505
+ export const Msg = {
506
+ myMessage: (): Message => ({ name: "MyMessage", ...MsgSource }),
507
+ };
508
+
509
+ const cmd = Elm.createCmd<Message>();
510
+
511
+ // Include the LoadSettings Model
512
+ export type Model = Readonly<{
513
+ // ...
514
+ }> & LoadSettings.Model;
515
+
516
+ export const init = (): [Model, Elm.Cmd<Message>] => {
517
+ const model: Model = {
518
+ // Spread the initial model from LoadSettings
519
+ ...LoadSettings.init(),
520
+ // ...
521
+ };
522
+
523
+ // Return the model and dispatch the LoadSettings message
524
+ return [model, cmd.ofMsg(LoadSettings.Msg.loadSettings())];
525
+ };
526
+
527
+ // In our update function, we first distinguish between the sources of the messages
528
+ export const update = (model: Model, msg: Message): Elm.UpdateReturnType<Model, Message> => {
529
+ switch (msg.source) {
530
+ case "Composition":
531
+ // Then call the update function for the local messages
532
+ return updateComposition(model, msg);
533
+
534
+ case "LoadSettings":
535
+ // Or call the update function for the LoadSettings messages
536
+ return LoadSettings.update(model, msg);
537
+ }
538
+ }
539
+
540
+ // For the msg parameter we use the local CompositionMessage type
541
+ const updateComposition = (model: Model, msg: CompositionMessage): Elm.UpdateReturnType<Model, Message> => {
542
+ switch (msg.name) {
543
+ case "MyMessage":
544
+ return [{}];
545
+ }
546
+ }
547
+ ```
548
+
549
+ ## Call back parent components
550
+
551
+ Since each component has its own model and messages, communication with parent components is done via callback functions.
552
+
553
+ To inform the parent component about some action, let's say to close a dialog form, you do the following:
554
+
555
+ 1. Create a message
556
+
557
+ ```ts Dialog.ts
558
+ export type Message =
559
+ ...
560
+ | { name: "Close" }
561
+ ...
562
+
563
+ export const Msg = {
564
+ ...
565
+ close: (): Message => ({ name: "Close" }),
566
+ ...
567
+ }
568
+ ```
569
+
570
+ 1. Define a callback function property in the **Props**:
571
+
572
+ ```ts Dialog.ts
573
+ export type Props = {
574
+ onClose: () => void,
575
+ };
576
+ ```
577
+
578
+ 1. Handle the message and call the callback function:
579
+
580
+ ```ts Dialog.ts
581
+ ...
582
+ case "Close":
583
+ props.onClose();
584
+ return [{}];
585
+ ...
586
+ ```
587
+
588
+ 1. In the **render** method of the parent component pass the callback as prop
589
+
590
+ ```tsx Parent.tsx
591
+ ...
592
+ <Dialog onClose={() => this.dispatch(Msg.closeDialog())}>
593
+ ...
594
+ ```
595
+
596
+ ## Testing
597
+
598
+ To test your **update** function you can use some helper functions in `react-elmish/dist/Testing`:
599
+
600
+ | Function | Description |
601
+ | --- | --- |
602
+ | `getOfMsgParams` | Extracts the messages out of a command |
603
+ | `execCmd` | Executes the provided command and returns an array of all messages. |
604
+
605
+ ### Testing the model and simple message commands
606
+
607
+ ```ts
608
+ import * as Testing from "react-elmish/dist/Testing";
609
+
610
+ ...
611
+ it("returns the correct model and cmd", () => {
612
+ // arrange
613
+ const model = // create model for test
614
+ const props = // create props for test
615
+ const msg = Shared.Msg.test();
616
+
617
+ const expectedValue = // what you expect in the model
618
+ const expectedCmds = [
619
+ Shared.Msg.expectedMsg1("arg"),
620
+ Shared.Msg.expectedMsg2(),
621
+ ];
622
+
623
+ // act
624
+ const [newModel, cmd] = Shared.update(model, msg, props);
625
+
626
+ // assert
627
+ expect(newModel.value).toEqual(expectedValue);
628
+ expect(Testing.getOfMsgParams(cmd)).toEqual(expectedCmds);
629
+ });
630
+ ...
631
+ ```
632
+
633
+ ### Testing all (async) messages
634
+
635
+ With `execCmd` you can execute all commands in a test scenario. All functions are called and awaited. The function returns all new messages (success or error messages).
636
+
637
+ It also resolves for `attempt` functions if the called functions succeed. And it rejects for `perform` functions if the called functions fail.
638
+
639
+ ```ts
640
+ import * as Testing from "react-elmish/dist/Testing";
641
+
642
+ ...
643
+ it("returns the correct cmd", () => {
644
+ // arrange
645
+ const model = { /* create model */ };
646
+ const props = { /* create props */ };
647
+ const msg = Shared.Msg.asyncTest();
648
+
649
+ // mock function which is called when the "AsyncTest" message is handled
650
+ const functionMock = jest.fn();
651
+
652
+ // act
653
+ const [, cmd] = Shared.update(model, msg, props);
654
+ const messages = await Testing.execCmd(cmd);
655
+
656
+ // assert
657
+ expect(functionMock).toBeCalled();
658
+ expect(messages).toEqual([Shared.Msg.asyncTestSuccess()])
659
+ });
660
+ ...
661
+ ```