Skip to content

Commit

Permalink
feat: add support for subscriptions when using the useElmish hook
Browse files Browse the repository at this point in the history
  • Loading branch information
atheck committed Apr 1, 2022
1 parent 45f2468 commit c6baf2a
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 13 deletions.
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ Then you can call one of the functions of that object:
| `cmd.ofPromise.either` | Calls an async function and maps the result into a message. |
| `cmd.ofPromise.attempt` | Like `either` but ignores the success case. |
| `cmd.ofPromise.perform` | Like `either` but ignores the error case. |
| `cmd.ofSub` | Use this function to trigger a command in a subscription. |
### Dispatch a message
Expand Down Expand Up @@ -362,6 +363,91 @@ export function init (props: Props): InitResult {
};
```
## Subscriptions
### Working with external sources of events
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.
Let's define a `Model` and a `Message`:
```ts
type Message =
| { name: "timer", date: Date };

interface Model {
date: Date,
}

const Msg = {
timer: (date: Date): Message => ({ name: "timer", date }),
};
```
Now we define the `init` function and the `update` object:
```ts
const cmd = createCmd<Message>();

function init (props: Props): InitResult<Model, Message> {
return [{
date: new Date(),
}];
}

const update: UpdateMap<Props, Model, Message> = {
timer ({ date }) {
return [{ date }];
},
};
```
Then we write our `subscription` function:
```ts
function subscription (model: Model): SubscriptionResult<Message> {
const sub = (dispatch: Dispatch<Message>): void => {
setInterval(() => dispatch(Msg.timer(new Date())), 1000) as unknown as number;
}

return [cmd.ofSub(sub)];
}
```
This function gets the initialized model as parameter and returns a command.
In the function component we call `useElmish` and pass the subscription to it:
```ts
const [{ date }] = useElmish({ name: "Subscriptions", props, init, update, subscription })
```
You can define and aggregate multiple subscriptions with a call to `cmd.batch(...)`.
### Cleanup subscriptions
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.
Let's rewrite our `subscription` function:
```ts
function subscription (model: Model): SubscriptionResult<Message> {
let timer: NodeJS.Timer;

const sub = (dispatch: Dispatch<Message>): void => {
timer = setInterval(() => dispatch(Msg.timer(new Date())), 1000);
}

const destructor = () => {
clearInterval(timer1);
}

return [cmd.ofSub(sub), destructor];
}
```
Here we save the return value of `setInterval` and clear that interval in the returned `destructor` function.
## Setup
**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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-elmish",
"version": "2.1.0",
"version": "3.0.0",
"description": "Elmish for React using Typescript",
"author": "atheck",
"license": "MIT",
Expand Down
9 changes: 9 additions & 0 deletions src/Cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ class Command<TMsg> {
return commands.flat();
}

/**
* Command to call the subscriber.
* @param {Sub<TMsg>} sub The subscriber function.
*/
// eslint-disable-next-line class-methods-use-this
public ofSub (sub: Sub<TMsg>): Cmd<TMsg> {
return [sub];
}

/**
* Provides functionalities to create commands from simple functions.
*/
Expand Down
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Cmd, createCmd, Dispatch } from "./Cmd";
import { ElmComponent, InitResult, UpdateReturnType } from "./ElmComponent";
import { ErrorMessage, handleError, MsgSource, UpdateMap } from "./ElmUtilities";
import { ErrorMessage, errorMsg, handleError, MsgSource, UpdateMap } from "./ElmUtilities";
import { init, Logger, Message } from "./Init";
import { useElmish } from "./useElmish";
import { SubscriptionResult, useElmish } from "./useElmish";

export type {
Logger,
Expand All @@ -11,6 +11,7 @@ export type {
Dispatch,
InitResult,
UpdateReturnType,
SubscriptionResult,
MsgSource,
UpdateMap,
ErrorMessage,
Expand All @@ -20,6 +21,7 @@ export {
init,
createCmd,
ElmComponent,
errorMsg,
handleError,
useElmish,
};
20 changes: 18 additions & 2 deletions src/useElmish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import { Cmd, Dispatch } from "./Cmd";
import { dispatchMiddleware, LoggerService } from "./Init";
import { InitFunction, UpdateFunction, UpdateReturnType } from "./ElmComponent";
import { MessageBase, Nullable, UpdateMap } from "./ElmUtilities";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";

export type SubscriptionResult<TMessage> = [Cmd<TMessage>, (() => void)?];
type Subscription<TModel, TMessage> = (model: TModel) => SubscriptionResult<TMessage>;

interface UseElmishOptions<TProps, TModel, TMessage extends MessageBase> {
name: string,
props: TProps,
init: InitFunction<TProps, TModel, TMessage>,
update: UpdateFunction<TProps, TModel, TMessage> | UpdateMap<TProps, TModel, TMessage>,
subscription?: Subscription<TModel, TMessage>,
}

/**
Expand All @@ -18,7 +22,7 @@ interface UseElmishOptions<TProps, TModel, TMessage extends MessageBase> {
* @example
* const [model, dispatch] = useElmish({ props, init, update, name: "MyComponent" });
*/
export function useElmish<TProps, TModel, TMessage extends MessageBase> ({ props, init, update, name }: UseElmishOptions<TProps, TModel, TMessage>): [TModel, Dispatch<TMessage>] {
export function useElmish<TProps, TModel, TMessage extends MessageBase> ({ name, props, init, update, subscription }: UseElmishOptions<TProps, TModel, TMessage>): [TModel, Dispatch<TMessage>] {
let reentered = false;
const buffer: TMessage [] = [];
let currentModel: Partial<TModel> = {};
Expand Down Expand Up @@ -102,6 +106,18 @@ export function useElmish<TProps, TModel, TMessage extends MessageBase> ({ props
}
}

useEffect(() => {
if (subscription) {
const [subCmd, destructor] = subscription(initializedModel as TModel);

execCmd(subCmd);

if (destructor) {
return destructor;
}
}
}, []);

return [initializedModel, dispatch];
}

Expand Down
54 changes: 48 additions & 6 deletions tests/useElmish.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Cmd, createCmd, UpdateReturnType, useElmish } from "../src";
import { Cmd, createCmd, SubscriptionResult, UpdateReturnType, useElmish } from "../src";
import { render, RenderResult, waitFor } from "@testing-library/react";
import { useEffect } from "react";

Expand All @@ -16,6 +16,7 @@ interface Model {
interface Props {
init: () => [Model, Cmd<Message>],
update: (model: Model, msg: Message, props: Props) => UpdateReturnType<Model, Message>,
subscription?: (model: Model) => SubscriptionResult<Message>,
}

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

describe("Hooks", () => {
describe("useElmish", () => {
it("calls the init function", () => {
// arrange
const init = jest.fn().mockReturnValue([{}, []]);
Expand Down Expand Up @@ -128,11 +129,52 @@ describe("Hooks", () => {
// assert
expect(componentModel).toStrictEqual({ value1: "Second", value2: "Third" });
});

it("calls the subscription", () => {
// arrange
const mockSub = jest.fn();
const mockSubscription = jest.fn().mockReturnValue([cmd.ofSub(mockSub)]);
const [initModel, initCmd] = defaultInit(cmd.none);
const props: Props = {
init: () => [initModel, initCmd],
update: defaultUpdate,
subscription: mockSubscription,

};

// act
renderComponent(props);

// assert
expect(mockSubscription).toHaveBeenCalledWith(initModel);
expect(mockSub).toHaveBeenCalledWith(expect.anything());
});

it("calls the subscriptions destructor if provided", () => {
// arrange
const mockDestructor = jest.fn();
const mockSubscription = jest.fn().mockReturnValue([cmd.none, mockDestructor]);
const [initModel, initCmd] = defaultInit(cmd.none);
const props: Props = {
init: () => [initModel, initCmd],
update: defaultUpdate,
subscription: mockSubscription,

};

// act
const api = renderComponent(props);

api.unmount();

// assert
expect(mockDestructor).toHaveBeenCalledWith();
});
});

function TestComponent (props: Props): JSX.Element {
const { init, update } = props;
const [model] = useElmish({ props, init, update, name: "Test" });
const { init, update, subscription } = props;
const [model] = useElmish({ props, init, update, subscription, name: "Test" });

componentModel = model;

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

function TestComponentWithEffect (props: Props): JSX.Element {
const { init, update } = props;
const [model, dispatch] = useElmish({ props, init, update, name: "Test" });
const { init, update, subscription } = props;
const [model, dispatch] = useElmish({ props, init, update, subscription, name: "Test" });

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

0 comments on commit c6baf2a

Please sign in to comment.