Skip to content

Commit c6baf2a

Browse files
committed
feat: add support for subscriptions when using the useElmish hook
1 parent 45f2468 commit c6baf2a

File tree

7 files changed

+168
-13
lines changed

7 files changed

+168
-13
lines changed

README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ Then you can call one of the functions of that object:
269269
| `cmd.ofPromise.either` | Calls an async function and maps the result into a message. |
270270
| `cmd.ofPromise.attempt` | Like `either` but ignores the success case. |
271271
| `cmd.ofPromise.perform` | Like `either` but ignores the error case. |
272+
| `cmd.ofSub` | Use this function to trigger a command in a subscription. |
272273
273274
### Dispatch a message
274275
@@ -362,6 +363,91 @@ export function init (props: Props): InitResult {
362363
};
363364
```
364365
366+
## Subscriptions
367+
368+
### Working with external sources of events
369+
370+
If you want to use external sources of events (e.g. a timer), you can use a `subscription`. With this those events can be processed by our `update` handler.
371+
372+
Let's define a `Model` and a `Message`:
373+
374+
```ts
375+
type Message =
376+
| { name: "timer", date: Date };
377+
378+
interface Model {
379+
date: Date,
380+
}
381+
382+
const Msg = {
383+
timer: (date: Date): Message => ({ name: "timer", date }),
384+
};
385+
```
386+
387+
Now we define the `init` function and the `update` object:
388+
389+
```ts
390+
const cmd = createCmd<Message>();
391+
392+
function init (props: Props): InitResult<Model, Message> {
393+
return [{
394+
date: new Date(),
395+
}];
396+
}
397+
398+
const update: UpdateMap<Props, Model, Message> = {
399+
timer ({ date }) {
400+
return [{ date }];
401+
},
402+
};
403+
```
404+
405+
Then we write our `subscription` function:
406+
407+
```ts
408+
function subscription (model: Model): SubscriptionResult<Message> {
409+
const sub = (dispatch: Dispatch<Message>): void => {
410+
setInterval(() => dispatch(Msg.timer(new Date())), 1000) as unknown as number;
411+
}
412+
413+
return [cmd.ofSub(sub)];
414+
}
415+
```
416+
417+
This function gets the initialized model as parameter and returns a command.
418+
419+
In the function component we call `useElmish` and pass the subscription to it:
420+
421+
```ts
422+
const [{ date }] = useElmish({ name: "Subscriptions", props, init, update, subscription })
423+
```
424+
425+
You can define and aggregate multiple subscriptions with a call to `cmd.batch(...)`.
426+
427+
### Cleanup subscriptions
428+
429+
In the solution above `setInterval` will trigger events even if the component is removed from the DOM. To cleanup subscriptions, we can return a `destructor` function from the subscription the same as in the `useEffect` hook.
430+
431+
Let's rewrite our `subscription` function:
432+
433+
```ts
434+
function subscription (model: Model): SubscriptionResult<Message> {
435+
let timer: NodeJS.Timer;
436+
437+
const sub = (dispatch: Dispatch<Message>): void => {
438+
timer = setInterval(() => dispatch(Msg.timer(new Date())), 1000);
439+
}
440+
441+
const destructor = () => {
442+
clearInterval(timer1);
443+
}
444+
445+
return [cmd.ofSub(sub), destructor];
446+
}
447+
```
448+
449+
Here we save the return value of `setInterval` and clear that interval in the returned `destructor` function.
450+
365451
## Setup
366452
367453
**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.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-elmish",
3-
"version": "2.1.0",
3+
"version": "3.0.0",
44
"description": "Elmish for React using Typescript",
55
"author": "atheck",
66
"license": "MIT",

src/Cmd.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ class Command<TMsg> {
4040
return commands.flat();
4141
}
4242

43+
/**
44+
* Command to call the subscriber.
45+
* @param {Sub<TMsg>} sub The subscriber function.
46+
*/
47+
// eslint-disable-next-line class-methods-use-this
48+
public ofSub (sub: Sub<TMsg>): Cmd<TMsg> {
49+
return [sub];
50+
}
51+
4352
/**
4453
* Provides functionalities to create commands from simple functions.
4554
*/

src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { Cmd, createCmd, Dispatch } from "./Cmd";
22
import { ElmComponent, InitResult, UpdateReturnType } from "./ElmComponent";
3-
import { ErrorMessage, handleError, MsgSource, UpdateMap } from "./ElmUtilities";
3+
import { ErrorMessage, errorMsg, handleError, MsgSource, UpdateMap } from "./ElmUtilities";
44
import { init, Logger, Message } from "./Init";
5-
import { useElmish } from "./useElmish";
5+
import { SubscriptionResult, useElmish } from "./useElmish";
66

77
export type {
88
Logger,
@@ -11,6 +11,7 @@ export type {
1111
Dispatch,
1212
InitResult,
1313
UpdateReturnType,
14+
SubscriptionResult,
1415
MsgSource,
1516
UpdateMap,
1617
ErrorMessage,
@@ -20,6 +21,7 @@ export {
2021
init,
2122
createCmd,
2223
ElmComponent,
24+
errorMsg,
2325
handleError,
2426
useElmish,
2527
};

src/useElmish.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@ import { Cmd, Dispatch } from "./Cmd";
22
import { dispatchMiddleware, LoggerService } from "./Init";
33
import { InitFunction, UpdateFunction, UpdateReturnType } from "./ElmComponent";
44
import { MessageBase, Nullable, UpdateMap } from "./ElmUtilities";
5-
import { useCallback, useState } from "react";
5+
import { useCallback, useEffect, useState } from "react";
6+
7+
export type SubscriptionResult<TMessage> = [Cmd<TMessage>, (() => void)?];
8+
type Subscription<TModel, TMessage> = (model: TModel) => SubscriptionResult<TMessage>;
69

710
interface UseElmishOptions<TProps, TModel, TMessage extends MessageBase> {
811
name: string,
912
props: TProps,
1013
init: InitFunction<TProps, TModel, TMessage>,
1114
update: UpdateFunction<TProps, TModel, TMessage> | UpdateMap<TProps, TModel, TMessage>,
15+
subscription?: Subscription<TModel, TMessage>,
1216
}
1317

1418
/**
@@ -18,7 +22,7 @@ interface UseElmishOptions<TProps, TModel, TMessage extends MessageBase> {
1822
* @example
1923
* const [model, dispatch] = useElmish({ props, init, update, name: "MyComponent" });
2024
*/
21-
export function useElmish<TProps, TModel, TMessage extends MessageBase> ({ props, init, update, name }: UseElmishOptions<TProps, TModel, TMessage>): [TModel, Dispatch<TMessage>] {
25+
export function useElmish<TProps, TModel, TMessage extends MessageBase> ({ name, props, init, update, subscription }: UseElmishOptions<TProps, TModel, TMessage>): [TModel, Dispatch<TMessage>] {
2226
let reentered = false;
2327
const buffer: TMessage [] = [];
2428
let currentModel: Partial<TModel> = {};
@@ -102,6 +106,18 @@ export function useElmish<TProps, TModel, TMessage extends MessageBase> ({ props
102106
}
103107
}
104108

109+
useEffect(() => {
110+
if (subscription) {
111+
const [subCmd, destructor] = subscription(initializedModel as TModel);
112+
113+
execCmd(subCmd);
114+
115+
if (destructor) {
116+
return destructor;
117+
}
118+
}
119+
}, []);
120+
105121
return [initializedModel, dispatch];
106122
}
107123

tests/useElmish.spec.tsx

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Cmd, createCmd, UpdateReturnType, useElmish } from "../src";
1+
import { Cmd, createCmd, SubscriptionResult, UpdateReturnType, useElmish } from "../src";
22
import { render, RenderResult, waitFor } from "@testing-library/react";
33
import { useEffect } from "react";
44

@@ -16,6 +16,7 @@ interface Model {
1616
interface Props {
1717
init: () => [Model, Cmd<Message>],
1818
update: (model: Model, msg: Message, props: Props) => UpdateReturnType<Model, Message>,
19+
subscription?: (model: Model) => SubscriptionResult<Message>,
1920
}
2021

2122
function defaultInit (msg: Cmd<Message>): [Model, Cmd<Message>] {
@@ -47,7 +48,7 @@ function defaultUpdate (_model: Model, msg: Message): UpdateReturnType<Model, Me
4748
let componentModel: Model | undefined;
4849
const cmd = createCmd<Message>();
4950

50-
describe("Hooks", () => {
51+
describe("useElmish", () => {
5152
it("calls the init function", () => {
5253
// arrange
5354
const init = jest.fn().mockReturnValue([{}, []]);
@@ -128,11 +129,52 @@ describe("Hooks", () => {
128129
// assert
129130
expect(componentModel).toStrictEqual({ value1: "Second", value2: "Third" });
130131
});
132+
133+
it("calls the subscription", () => {
134+
// arrange
135+
const mockSub = jest.fn();
136+
const mockSubscription = jest.fn().mockReturnValue([cmd.ofSub(mockSub)]);
137+
const [initModel, initCmd] = defaultInit(cmd.none);
138+
const props: Props = {
139+
init: () => [initModel, initCmd],
140+
update: defaultUpdate,
141+
subscription: mockSubscription,
142+
143+
};
144+
145+
// act
146+
renderComponent(props);
147+
148+
// assert
149+
expect(mockSubscription).toHaveBeenCalledWith(initModel);
150+
expect(mockSub).toHaveBeenCalledWith(expect.anything());
151+
});
152+
153+
it("calls the subscriptions destructor if provided", () => {
154+
// arrange
155+
const mockDestructor = jest.fn();
156+
const mockSubscription = jest.fn().mockReturnValue([cmd.none, mockDestructor]);
157+
const [initModel, initCmd] = defaultInit(cmd.none);
158+
const props: Props = {
159+
init: () => [initModel, initCmd],
160+
update: defaultUpdate,
161+
subscription: mockSubscription,
162+
163+
};
164+
165+
// act
166+
const api = renderComponent(props);
167+
168+
api.unmount();
169+
170+
// assert
171+
expect(mockDestructor).toHaveBeenCalledWith();
172+
});
131173
});
132174

133175
function TestComponent (props: Props): JSX.Element {
134-
const { init, update } = props;
135-
const [model] = useElmish({ props, init, update, name: "Test" });
176+
const { init, update, subscription } = props;
177+
const [model] = useElmish({ props, init, update, subscription, name: "Test" });
136178

137179
componentModel = model;
138180

@@ -146,8 +188,8 @@ function renderComponent (props: Props): RenderResult {
146188
}
147189

148190
function TestComponentWithEffect (props: Props): JSX.Element {
149-
const { init, update } = props;
150-
const [model, dispatch] = useElmish({ props, init, update, name: "Test" });
191+
const { init, update, subscription } = props;
192+
const [model, dispatch] = useElmish({ props, init, update, subscription, name: "Test" });
151193

152194
if (model.value1 === "") {
153195
setTimeout(() => dispatch({ name: "First" }), 5);

0 commit comments

Comments
 (0)