From c6baf2a9f5034cd4bd550a83656f9c6e1221f918 Mon Sep 17 00:00:00 2001 From: Henrik Fuchs Date: Fri, 1 Apr 2022 21:24:05 +0200 Subject: [PATCH] feat: add support for subscriptions when using the useElmish hook --- README.md | 86 ++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/Cmd.ts | 9 +++++ src/index.ts | 6 ++- src/useElmish.ts | 20 +++++++++- tests/useElmish.spec.tsx | 54 ++++++++++++++++++++++--- 7 files changed, 168 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7b56feb..64ae5af 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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(); + +function init (props: Props): InitResult { + return [{ + date: new Date(), + }]; +} + +const update: UpdateMap = { + timer ({ date }) { + return [{ date }]; + }, +}; +``` + +Then we write our `subscription` function: + +```ts +function subscription (model: Model): SubscriptionResult { + const sub = (dispatch: Dispatch): 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 { + let timer: NodeJS.Timer; + + const sub = (dispatch: Dispatch): 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. diff --git a/package-lock.json b/package-lock.json index dcb8dc6..d0272f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-elmish", - "version": "2.1.0", + "version": "3.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "react-elmish", - "version": "2.1.0", + "version": "3.0.0", "license": "MIT", "devDependencies": { "@babel/cli": "7.17.6", diff --git a/package.json b/package.json index 7820d57..2bcc386 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/Cmd.ts b/src/Cmd.ts index 55a0538..d16fc1b 100644 --- a/src/Cmd.ts +++ b/src/Cmd.ts @@ -40,6 +40,15 @@ class Command { return commands.flat(); } + /** + * Command to call the subscriber. + * @param {Sub} sub The subscriber function. + */ + // eslint-disable-next-line class-methods-use-this + public ofSub (sub: Sub): Cmd { + return [sub]; + } + /** * Provides functionalities to create commands from simple functions. */ diff --git a/src/index.ts b/src/index.ts index 0c4ea38..653b050 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, @@ -11,6 +11,7 @@ export type { Dispatch, InitResult, UpdateReturnType, + SubscriptionResult, MsgSource, UpdateMap, ErrorMessage, @@ -20,6 +21,7 @@ export { init, createCmd, ElmComponent, + errorMsg, handleError, useElmish, }; \ No newline at end of file diff --git a/src/useElmish.ts b/src/useElmish.ts index 9e96fd6..83cbc00 100644 --- a/src/useElmish.ts +++ b/src/useElmish.ts @@ -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 = [Cmd, (() => void)?]; +type Subscription = (model: TModel) => SubscriptionResult; interface UseElmishOptions { name: string, props: TProps, init: InitFunction, update: UpdateFunction | UpdateMap, + subscription?: Subscription, } /** @@ -18,7 +22,7 @@ interface UseElmishOptions { * @example * const [model, dispatch] = useElmish({ props, init, update, name: "MyComponent" }); */ -export function useElmish ({ props, init, update, name }: UseElmishOptions): [TModel, Dispatch] { +export function useElmish ({ name, props, init, update, subscription }: UseElmishOptions): [TModel, Dispatch] { let reentered = false; const buffer: TMessage [] = []; let currentModel: Partial = {}; @@ -102,6 +106,18 @@ export function useElmish ({ props } } + useEffect(() => { + if (subscription) { + const [subCmd, destructor] = subscription(initializedModel as TModel); + + execCmd(subCmd); + + if (destructor) { + return destructor; + } + } + }, []); + return [initializedModel, dispatch]; } diff --git a/tests/useElmish.spec.tsx b/tests/useElmish.spec.tsx index 94f70c2..e72904e 100644 --- a/tests/useElmish.spec.tsx +++ b/tests/useElmish.spec.tsx @@ -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"; @@ -16,6 +16,7 @@ interface Model { interface Props { init: () => [Model, Cmd], update: (model: Model, msg: Message, props: Props) => UpdateReturnType, + subscription?: (model: Model) => SubscriptionResult, } function defaultInit (msg: Cmd): [Model, Cmd] { @@ -47,7 +48,7 @@ function defaultUpdate (_model: Model, msg: Message): UpdateReturnType(); -describe("Hooks", () => { +describe("useElmish", () => { it("calls the init function", () => { // arrange const init = jest.fn().mockReturnValue([{}, []]); @@ -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; @@ -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);