Skip to content

Commit 7654693

Browse files
committed
feat: optionally return subscription dispose function directly from subscription function (#46)
1 parent 87a82a5 commit 7654693

File tree

5 files changed

+81
-42
lines changed

5 files changed

+81
-42
lines changed

README.md

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -415,11 +415,11 @@ Then we write our `subscription` function:
415415
416416
```ts
417417
function subscription (model: Model): SubscriptionResult<Message> {
418-
const sub = (dispatch: Dispatch<Message>): void => {
419-
setInterval(() => dispatch(Msg.timer(new Date())), 1000) as unknown as number;
418+
const sub = (dispatch: Dispatch<Message>) => {
419+
setInterval(() => dispatch(Msg.timer(new Date())), 1000);
420420
}
421421

422-
return [cmd.ofSub(sub)];
422+
return [sub];
423423
}
424424
```
425425
@@ -431,31 +431,29 @@ In the function component we call `useElmish` and pass the subscription to it:
431431
const [{ date }] = useElmish({ name: "Subscriptions", props, init, update, subscription })
432432
```
433433
434-
You can define and aggregate multiple subscriptions with a call to `cmd.batch(...)`.
434+
Because the return type is an array, you can define and return multiple subscription functions.
435435
436436
### Cleanup subscriptions
437437
438-
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.
438+
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 the same way as in the `useEffect` hook.
439439
440440
Let's rewrite our `subscription` function:
441441
442442
```ts
443443
function subscription (model: Model): SubscriptionResult<Message> {
444-
let timer: NodeJS.Timer;
444+
const sub = (dispatch: Dispatch<Message>) => {
445+
const timer = setInterval(() => dispatch(Msg.timer(new Date())), 1000);
445446

446-
const sub = (dispatch: Dispatch<Message>): void => {
447-
timer = setInterval(() => dispatch(Msg.timer(new Date())), 1000);
448-
}
449-
450-
const destructor = () => {
451-
clearInterval(timer);
447+
return () => {
448+
clearInterval(timer);
449+
}
452450
}
453451

454-
return [cmd.ofSub(sub), destructor];
452+
return [sub];
455453
}
456454
```
457455
458-
Here we save the return value of `setInterval` and clear that interval in the returned `destructor` function.
456+
The destructor is called when the component is removed from the DOM.
459457
460458
## Setup
461459

src/Testing/execSubscription.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Dispatch } from "react";
22
import type { Message } from "../Types";
3-
import type { Subscription } from "../useElmish";
3+
import { subscriptionIsFunctionArray, type Subscription } from "../useElmish";
44
import { execCmdWithDispatch } from "./execCmd";
55

66
function execSubscription<TProps, TModel, TMessage extends Message>(
@@ -17,7 +17,19 @@ function execSubscription<TProps, TModel, TMessage extends Message>(
1717
return noop;
1818
}
1919

20-
const [cmd, dispose] = subscription(model, props);
20+
const subscriptionResult = subscription(model, props);
21+
22+
if (subscriptionIsFunctionArray(subscriptionResult)) {
23+
const disposers = subscriptionResult.map((sub) => sub(dispatch)).filter((disposer) => disposer !== undefined);
24+
25+
return () => {
26+
for (const dispose of disposers) {
27+
dispose();
28+
}
29+
};
30+
}
31+
32+
const [cmd, dispose] = subscriptionResult;
2133

2234
execCmdWithDispatch<TMessage>(dispatch, cmd);
2335

src/mergeSubscriptions.spec.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { cmd } from "./cmd";
2-
import { execCmd } from "./Common";
32
import { mergeSubscriptions } from "./mergeSubscriptions";
43
import type { Message } from "./Types";
54
import type { SubscriptionResult } from "./useElmish";
@@ -47,10 +46,12 @@ describe("mergeSubscriptions", () => {
4746
const sub2 = (): SubscriptionResult<Message> => [cmd.ofSub(sub2Fn)];
4847

4948
const subscription = mergeSubscriptions(sub1, sub2);
50-
const [command] = subscription(model, props);
49+
const commands = subscription(model, props);
5150

5251
// act
53-
execCmd(mockDispatch, command);
52+
for (const command of commands) {
53+
command(mockDispatch);
54+
}
5455

5556
// assert
5657
expect(sub1Fn).toHaveBeenCalledWith(mockDispatch);
@@ -59,16 +60,20 @@ describe("mergeSubscriptions", () => {
5960

6061
it("executes all disposer functions", () => {
6162
// arrange
63+
const mockDispatch = jest.fn();
64+
6265
const dispose1 = jest.fn();
6366
const sub1 = (): SubscriptionResult<Message> => [cmd.ofSub(jest.fn()), dispose1];
6467
const dispose2 = jest.fn();
6568
const sub2 = (): SubscriptionResult<Message> => [cmd.ofSub(jest.fn()), dispose2];
6669

6770
const subscription = mergeSubscriptions(sub1, sub2);
68-
const [, dispose] = subscription(model, props);
71+
const commands = subscription(model, props);
6972

7073
// act
71-
dispose?.();
74+
for (const command of commands) {
75+
command(mockDispatch)?.();
76+
}
7277

7378
// assert
7479
expect(dispose1).toHaveBeenCalledTimes(1);

src/mergeSubscriptions.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,31 @@
1-
import { cmd } from "./cmd";
2-
import type { Message } from "./Types";
3-
import type { Subscription } from "./useElmish";
1+
import type { Dispatch, Message, Sub } from "./Types";
2+
import { subscriptionIsFunctionArray, type Subscription, type SubscriptionFunction } from "./useElmish";
3+
4+
type MergedSubscription<TProps, TModel, TMessage> = (model: TModel, props: TProps) => SubscriptionFunction<TMessage>[];
45

56
function mergeSubscriptions<TProps, TModel, TMessage extends Message>(
67
...subscriptions: (Subscription<TProps, TModel, TMessage> | undefined)[]
7-
): Subscription<TProps, TModel, TMessage> {
8+
): MergedSubscription<TProps, TModel, TMessage> {
89
return function mergedSubscription(model, props) {
9-
const results = subscriptions.map((sub) => sub?.(model, props));
10-
11-
const commands = results.map((sub) => sub?.[0]);
12-
const disposers = results.map((sub) => sub?.[1]);
13-
14-
return [
15-
cmd.batch(...commands),
16-
() => {
17-
for (const disposer of disposers) {
18-
disposer?.();
19-
}
20-
},
21-
];
10+
const results = subscriptions.map((sub) => sub?.(model, props)).filter((subscription) => subscription !== undefined);
11+
12+
const subscriptionFunctions = results.flatMap((result) => {
13+
if (subscriptionIsFunctionArray(result)) {
14+
return result;
15+
}
16+
17+
const [subCmd, dispose] = result;
18+
19+
return [...subCmd.map(mapToFn), () => () => dispose?.()];
20+
});
21+
22+
return subscriptionFunctions;
23+
};
24+
}
25+
26+
function mapToFn<TMessage>(cmd: Sub<TMessage>): (dispatch: Dispatch<TMessage>) => undefined {
27+
return (dispatch) => {
28+
cmd(dispatch);
2229
};
2330
}
2431

src/useElmish.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,14 @@ import { isReduxDevToolsEnabled, type ReduxDevTools } from "./reduxDevTools";
2222
* The return type of the `subscription` function.
2323
* @template TMessage The type of the messages discriminated union.
2424
*/
25-
type SubscriptionResult<TMessage> = [Cmd<TMessage>, (() => void)?];
25+
type SubscriptionResult<TMessage> = [Cmd<TMessage>, (() => void)?] | SubscriptionFunction<TMessage>[];
26+
type SubscriptionFunction<TMessage> = (dispatch: Dispatch<TMessage>) => (() => void) | undefined;
2627
type Subscription<TProps, TModel, TMessage> = (model: TModel, props: TProps) => SubscriptionResult<TMessage>;
2728

29+
function subscriptionIsFunctionArray(subscription: SubscriptionResult<unknown>): subscription is SubscriptionFunction<unknown>[] {
30+
return typeof subscription[0] === "function";
31+
}
32+
2833
/**
2934
* Options for the `useElmish` hook.
3035
* @interface UseElmishOptions
@@ -173,7 +178,19 @@ function useElmish<TProps, TModel, TMessage extends Message>({
173178
// biome-ignore lint/correctness/useExhaustiveDependencies: We want to run this effect only once
174179
useEffect(() => {
175180
if (subscription) {
176-
const [subCmd, destructor] = subscription(initializedModel, props);
181+
const subscriptionResult = subscription(initializedModel, props);
182+
183+
if (subscriptionIsFunctionArray(subscriptionResult)) {
184+
const destructors = subscriptionResult.map((sub) => sub(dispatch)).filter((destructor) => destructor !== undefined);
185+
186+
return function combinedDestructor() {
187+
for (const destructor of destructors) {
188+
destructor();
189+
}
190+
};
191+
}
192+
193+
const [subCmd, destructor] = subscriptionResult;
177194

178195
execCmd(dispatch, subCmd);
179196

@@ -239,6 +256,6 @@ function callUpdateMap<TProps, TModel, TMessage extends Message>(
239256
return updateMap[msgName](msg, model, props, options);
240257
}
241258

242-
export type { Subscription, SubscriptionResult, UseElmishOptions };
259+
export type { Subscription, SubscriptionFunction, SubscriptionResult, UseElmishOptions };
243260

244-
export { callUpdate, callUpdateMap, useElmish };
261+
export { callUpdate, callUpdateMap, subscriptionIsFunctionArray, useElmish };

0 commit comments

Comments
 (0)