Elmish for React using Typescript
npm install react-elmishThis library brings the elmish pattern to react.
- Installation
- Basic Usage
- More about messages
- Message parameters
- Dispatch commands in the update map or update function
- Dispatch a message
- Call an async function
- Dispatch a command from init
- Dispatching multiple commands
- Subscriptions
- Working with external sources of events
- Cleanup subscriptions
- Re-Initialize the component
- Dispose / Cleanup
- Immutability
- Testing
- Setup
- Error handling
- React life cycle management
- Deferring model updates and messages
- Call back parent components
- Composition
- With an UpdateMap
- With an update function
- Merge multiple subscriptions
- Testing
- Testing the init function
- Testing the update handler
- Combine update and execCmd
- Testing consecutive updates
- Testing subscriptions
- UI Tests
- Redux Dev Tools
- Migrations
- From v1.x to v2.x
- From v2.x to v3.x
- From v3.x to v4.x
- From v6.x to v7.x
- VS Code Snippets Extension
npm install react-elmish
An elmish component basically consists of the following parts:
- The Model holding the state of the component.
- The Props for the component.
- The Init function to create the initial model based on the props.
- The Messages to dispatch which modify the model.
- The Update function to modify the model based on a specific message.
- The View which renders the component based on the current model.
App.ts:
First import everything from react-elmish and declare the Message discriminated union type:
``ts
import { Cmd, InitResult, UpdateReturnType, UpdateMap } from "react-elmish";
export type Message =
| { name: "increment" }
| { name: "decrement" };
`
You can also create some convenience functions to create message objects:
`ts`
export const Msg = {
increment: (): Message => ({ name: "increment" }),
decrement: (): Message => ({ name: "decrement" }),
};
Next, declare the model:
`ts`
export interface Model {
value: number,
}
The props are optional:
`ts`
export interface Props {
initialValue: number,
}
To create the initial model we need an init function:
`ts`
export function init (props: Props): InitResult
return [
{
value: props.initialValue,
}
];
};
To update the model based on a message we need an UpdateMap object:
`ts
export const update: UpdateMap
increment (msg, model, props) {
return [{ value: model.value + 1 }];
},
decrement (msg, model, props) {
return [{ value: model.value - 1 }];
},
};
`
Note: When using an UpdateMap it is recommended to use camelCase for message names (e.g. "increment" instead of "Increment").
Alternatively we can use an update function:
`ts
export const update = (model: Model, msg: Msg, props: Props): UpdateReturnType
switch (msg.name) {
case "increment":
return [{ value: model.value + 1 }];
case "decrement":
return [{ value: model.value - 1 }];
}
};
`
> Note: If you are using typescript and typescript-eslint you should enable the switch-exhaustive-check rule.
App.tsx:
To put all this together and to render our component, we need a React component.
As a function component:
`tsx
// Import everything from the App.ts
import { init, update, Msg, Props } from "../App";
// Import the useElmish hook
import { useElmish } from "react-elmish";
function App (props: Props): JSX.Element {
// Call the useElmish hook, it returns the current model and the dispatch function
const [model, dispatch] = useElmish({ props, init, update, name: "App" });
return (
{model.value}
{/ dispatch messages /}
You can also write the component as a class component:
`tsx
// Import everything from the App.ts
import { Model, Message, Props, init, update, Msg } as Shared from "../App";
// Import the ElmComponent which extends React.Component
import { ElmComponent } from "react-elmish";
import React from "react";// Create an elmish class component
class App extends ElmComponent {
// Construct the component with the props and init function
constructor(props: Props) {
super(props, init, "App");
}
// Assign our update function to the component
update = update;
render(): React.ReactNode {
// Access the model
const { value } = this.model;
return (
{/ Display our current value /}
{value}
{/ Dispatch messages /}
);
}
`> Note: When using a class component, you can only use an
update function. Class components do not support UpdateMaps.You can use these components like any other React component.
> 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.
>
> 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).More about messages
$3
Messages can also have parameters. You can modify the example above and pass an optional step value to the Increment message:
`ts
export type Message =
| { name: "increment", step?: number }
...export const Msg = {
increment: (step?: number): Message => ({ name: "increment", step }),
...
}
`Then use this parameter in the update handler:
`ts
{
// ...
// We destructure the message parameter here
increment ({ step }) {
return [{ value: model.value + (step ?? 1)}]
}
// ...
};
`In the render method you can add another button to increment the value by 10:
`tsx
...
...
`Dispatch commands in the update map or update function
In addition to modifying the model, you can dispatch new commands here.
To do so, you can use the
cmd object:`ts
import { cmd } from "react-elmish";
`You can call one of the functions of that object:
| Function | Description |
|---|---|
|
cmd.ofMsg | Dispatches a new message. |
| cmd.batch | Aggregates an array of messages. |
| cmd.ofEither | Calls a function (sync or async) and maps the result into a message. |
| cmd.ofSuccess | Same as ofEither but ignores the error case. |
| cmd.ofError | Same as ofEither but ignores the success case. |
| cmd.ofNone | Same as ofEither but ignores both, the success case and the error case. |
| cmd.ofSub | Use this function to trigger a command in a subscription. |$3
Let's assume you have a message to display the description of the last called message:
`ts
export type Message =
...
| { name: "printLastMessage", message: string }
...export const Msg = {
...
printLastMessage: (message: string): Message => ({ name: "printLastMessage", message }),
...
}
`In the update map or update function you can dispatch that message like this:
`ts
{
increment () {
return [{ value: model.value + 1 }, cmd.ofMsg(Msg.printLastMessage("Incremented by one"))];
}
}
`This new message will immediately be dispatched after returning from the update handler.
$3
This way you can also call functions and async operations. For an async function like:
`ts
const loadSettings = async (arg1: string, arg2: number): Promise => {
const settings = await Storage.loadSettings();
return settings;
}
`you can define the following messages:
`ts
export type Messages =
...
| { name: "loadSettings" },
| { name: "settingsLoaded", settings: Settings }
| ErrorMessage
...export const Msg = {
...
loadSettings: (): Message => ({ name: "loadSettings" }),
settingsLoaded: (settings: Settings): Message => ({ name: "settingsLoaded", settings }),
...errorMsg,
...
};
`and handle the messages in the update function:
`ts
{
// ...
loadSettings () {
// Create a command out of the async function with the provided arguments
// If loadSettings resolves it dispatches "SettingsLoaded"
// If it fails it dispatches "Error"
// The return type of loadSettings must fit Msg.settingsLoaded
return [{}, cmd.ofEither(loadSettings, Msg.settingsLoaded, Msg.error, "firstArg", 123)];
}, settingsLoaded () {
return [{ settings: msg.settings }];
},
error () {
return handleError(msg.error);
},
// ...
};
`$3
The same way as in the
update map or function, you can also dispatch an initial command in the init function:`ts
export function init (props: Props): InitResult {
return [
{
value: props.initialValue,
},
cmd.ofMsg(Msg.loadData())
];
};
`$3
To dispatch more than one command from
init or update you can either use the cmd.batch function or simply return multiple commands:`ts
return [{}, cmd.ofMsg(Msg.loadData()), cmd.ofEither(doStuff, Msg.success, Msg.error)];
`Subscriptions
$3
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
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) => {
setInterval(() => dispatch(Msg.timer(new Date())), 1000);
} return [sub];
}
`This function gets the initialized model as parameter and returns a function that gets the
dispatch function as parameter. This function is called when the component is mounted.Because the return type of the
subscription function is an array, you can define and return multiple functions.In the function component we call
useElmish and pass the subscription to it:`ts
const [{ date }] = useElmish({ name: "Subscriptions", props, init, update, subscription })
`$3
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.Let's rewrite our
subscription function:`ts
function subscription (model: Model): SubscriptionResult {
const sub = (dispatch: Dispatch) => {
const timer = setInterval(() => dispatch(Msg.timer(new Date())), 1000); return () => {
clearInterval(timer);
}
}
return [sub];
}
`The destructor is called when the component is removed from the DOM.
Re-Initialize the component
If you want to re-initialize the component when a prop (or another value) changes, you can pass a
reInitOn array to the useElmish hook. This array contains the dependencies that trigger a re-initialization of the component.`tsx
interface Props {
changingValue: string;
}// ...
const [model, dispatch] = useElmish({ name: "ReInit", props, init, update, subscription, reInitOn: [props.changingValue] })
`This will re-initialize the component whenever the
changingValue prop changes. The init function is called again, and the model is reset to the initial state. Also the subscription is re-created.Dispose / Cleanup
If your component sets up resources during
init (e.g. WebSocket connections, timers, or other handles stored in the model), you can provide a dispose function to clean them up when the component unmounts.The
dispose function receives the current model, so you can access any state needed for cleanup:`ts
function dispose(model: Model): void {
model.connection?.close();
}
`Pass it to the
useElmish hook:`tsx
const [model, dispatch] = useElmish({ name: "App", props, init, update, dispose });
`Or to a class component via the fourth constructor parameter:
`tsx
class App extends ElmComponent {
constructor(props: Props) {
super(props, init, "App", dispose);
} // ...
}
`The
dispose function is called:- When the component is unmounted.
- When
reInitOn dependencies change (before the new init call), cleaning up the old session.> Note: The
dispose function does not receive a dispatch function. It is intended purely for side-effect cleanup, not for dispatching messages, since the component is being torn down.Immutability
If you want to use immutable data structures, you can use the imports from "react-elmish/immutable". This version of the
useElmish hook returns an immutable model.`tsx
import { useElmish } from "react-elmish/immutable";function App(props: Props): JSX.Element {
const [model, dispatch] = useElmish({ props, init, update, name: "App" });
model.value = 42; // This will throw an error
return (
// ...
);
}
`You can simply update the draft of the model like this:
`ts
import { type UpdateMap } from "react-elmish/immutable";const updateMap: UpdateMap = {
increment(_msg, model) {
model.value += 1;
return [];
},
decrement(_msg, model) {
model.value -= 1;
return [];
},
commandOnly() {
// This will not update the model but only dispatch a command
return [cmd.ofMsg(Msg.increment())];
},
doNothing() {
// This does nothing
return [];
},
};
`$3
If you want to test your component with immutable data structures, you can use the
react-elmish/testing/immutable module. This module provides the same functions as the normal testing module.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.
`ts
import { init } from "react-elmish";const myLogger = {
debug(...args: unknown []) {
console.debug(...args);
},
info(...args: unknown []) {
console.info(...args);
},
error(...args: unknown []) {
console.error(...args);
},
}
init({
logger: myLogger,
errorMiddleware: error => Toast.error(error.message),
dispatchMiddleware: msg => myLogger.debug(msg),
});
`The error middleware function is called by the
handleError function (see Error handling).The dispatch middleware function is called whenever a Message is dispatched.
Error handling
You can handle errors easily with the following pattern.
1. Add an error message:
`ts
import { ErrorMessage, errorHandler, errorMsg, handleError } from "react-elmish"; export type Message =
// | ...
| ErrorMessage;
`1. Optionally add the convenient function to the
Msg object:
`ts
export const Msg = {
// ...
...errorMsg,
}
`1. Handle the error message
1. In the
update function:
`ts
// ...
case "error":
return handleError(msg.error);
// ...
` 1. Or in the
UpdateMap:
`ts
const updateMap = {
// ...
error ({ error }) {
return handleError(error);
}
};
` You can also use the
errorHandler helper function:
`ts
const updateMap = {
// ...
...errorHandler()
};
`The handleError function then calls your error handling middleware.
React life cycle management
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.`ts
class App extends ElmComponent {
...
componentDidMount() {
super.componentDidMount(); // your code
}
componentWillUnmount() {
super.componentWillUnmount();
// your code
}
...
}
`In a functional component you can use the useEffect hook as normal.
Deferring model updates and messages
Sometimes you want to always dispatch a message or update the model in all cases. You can use the
defer function from the options parameter to do this. The options parameter is the fourth parameter of the update function.Without the
defer function, you would have to return the model and the command in all cases:`ts
const update: UpdateMap = {
deferSomething (_msg, model) {
if (model.someCondition) {
return [{ alwaysUpdate: "someValue", extra: "extra" }, cmd.ofMsg(Msg.alwaysExecute())];
} return [{ alwaysUpdate: "someValue" }, cmd.ofMsg(Msg.doSomethingElse()), cmd.ofMsg(Msg.alwaysExecute())];
},
...LoadSettings.update,
};
`Here we always want to update the model with the
alwaysUpdate property and always dispatch the alwaysExecute message.With the
defer function, you can do this:`ts
const update: UpdateMap = {
deferSomething (_msg, model, _props, { defer }) {
defer({ alwaysUpdate: "someValue" }, cmd.ofMsg(Msg.alwaysExecute())); if (model.someCondition) {
return [{ extra: "extra" }];
}
return [{}, cmd.ofMsg(Msg.doSomethingElse())];
},
...LoadSettings.update,
};
`The
defer function can be called multiple times. Model updates and commands are then aggregated. Model updates by the return value overwrite the deferred model updates, while deferred messages are dispatched after the returned messages.Call back parent components
Since each component has its own model and messages, communication with parent components is done via callback functions.
To inform the parent component about some action, let's say to close a dialog form, you do the following:
1. Create a message
`ts Dialog.ts
export type Message =
...
| { name: "close" }
... export const Msg = {
...
close: (): Message => ({ name: "close" }),
...
}
`1. Define a callback function property in the Props:
`ts Dialog.ts
export type Props = {
onClose: () => void,
};
`1. Handle the message and call the callback function:
`ts Dialog.ts
{
// ...
close () {
return [{}, cmd.ofError(props.onClose, Msg.error)];
}
// ...
};
`1. In the render method of the parent component pass the callback as prop
`tsx Parent.tsx
...
`Composition
If you have some business logic that you want to reuse in other components, you can do this by using different sources for messages.
$3
Let's say you want to load some settings, you can write a module like this:
`ts LoadSettings.ts
import { cmd, ErrorMessage, UpdateMap, handleError } from "react-elmish";export interface Settings {
// ...
}
export type Message =
| { name: "loadSettings" }
| { name: "settingsLoaded", settings: Settings }
| ErrorMessage;
export const Msg = {
loadSettings: (): Message => ({ name: "loadSettings" }),
settingsLoaded: (settings: Settings): Message => ({ name: "settingsLoaded", settings }),
error: (error: Error): Message => ({ name: "error", error }),
};
export interface Model {
settings: Settings | null,
}
export function init (): Model {
return {
settings: null
};
}
export const update: UpdateMap = {
loadSettings () {
return [{}, cmd.ofEither(loadSettings, Msg.settingsLoaded, Msg.error)];
}
settingsLoaded ({ settings }) {
return [{ settings }];
}
error ({ error }) {
return handleError(error);
}
};
async function loadSettings (): Promise {
// Call some service (e.g. database or backend)
return {};
}
`> Note: This module has no View.
Now let's integrate the LoadSettings module in our component:
`ts Composition.ts
// Import the LoadSettings module
import * as LoadSettings from "./LoadSettings";
import { cmd, InitResult, UpdateMap } from "react-elmish";// Here we define our local messages
type Message =
| { name: "myMessage" }
| LoadSettings.Message;
// And spread the Msg of LoadSettings object
export const Msg = {
myMessage: (): Message => ({ name: "myMessage" }),
...LoadSettings.Msg,
};
interface Props {}
// Extend the LoadSettings model
interface Model extends LoadSettings.Model {
// ...
}
function init (): InitResult {
// Return the model and dispatch the LoadSettings message
return [
{
// Spread the initial model from LoadSettings
...LoadSettings.init(),
// ...
},
cmd.ofMsg(Msg.loadSettings())
];
};
// Spread the UpdateMap of LoadSettings into our update map
const update: UpdateMap = {
myMessage () {
return [{}];
},
...LoadSettings.update,
// You can overwrite the LoadSettings messages handlers here
settingsLoaded (_msg, _model, _props, { defer, callBase }) {
// Use defer and callBase to execute the original handler function:
defer(...callBase(LoadSettings.settingsLoaded));
// Do additional stuff
return [{ / ... / }];
}
};
`$3
Let's say you want to load some settings, you can write a module like this:
`ts LoadSettings.ts
import { cmd, InitResult, MsgSource, ErrorMessage, UpdateReturnType, handleError } from "react-elmish";export type Settings = {
// ...
};
// We use a MsgSource to differentiate between the messages
type MessageSource = MsgSource<"LoadSettings">;
// Add that MessageSource to all the messages
export type Message =
| { name: "loadSettings" } & MessageSource
| { name: "settingsLoaded", settings: Settings } & MessageSource
| ErrorMessage & MessageSource
// Do the same for the convenient functions
const MsgSource: MessageSource = { source: "LoadSettings" };
export const Msg = {
loadSettings: (): Message => ({ name: "loadSettings", ...MsgSource }),
settingsLoaded: (settings: Settings): Message => ({ name: "settingsLoaded", settings, ...MsgSource }),
error: (error: Error): Message => ({ name: "error", error, ...MsgSource }),
};
export interface Model {
settings: Settings | null,
}
export function init (): InitResult {
return [{ settings: null }];
}
export function update (_model: Model, msg: Message): UpdateReturnType {
switch (msg.name) {
case "loadSettings":
return [{}, cmd.ofEither(loadSettings, Msg.settingsLoaded, Msg.error)];
case "settingsLoaded":
return [{ settings: msg.settings }];
case "error":
return handleError(msg.error);
}
}
async function loadSettings (): Promise {
// Call some service (e.g. database or backend)
return {};
}
`> Note: This module has no View.
In other components where we want to use this LoadSettings module, we also need a message source:
`ts Composition.ts
import { cmd, InitResult, MsgSource, UpdateReturnType } from "react-elmish";
// Import the LoadSettings module
import * as LoadSettings from "./LoadSettings";// Create a message source for this module
type MessageSource = MsgSource<"Composition">;
// Here we define our local messages
// We don't need to export them
type CompositionMessage =
| { name: "myMessage" } & MessageSource;
// Combine the local messages and the ones from LoadSettings
export type Message =
| CompositionMessage
| LoadSettings.Message;
const MsgSource: MessageSource = { source: "Composition" };
export const Msg = {
myMessage: (): Message => ({ name: "myMessage", ...MsgSource }),
...LoadSettings.Msg,
};
// Include the LoadSettings Model
export interface Model extends LoadSettings.Model {
// ...
}
export function init (): InitResult {
// Return the model and dispatch the LoadSettings message
return [
{
// Spread the initial model from LoadSettings
...LoadSettings.init(),
// ...
},
cmd.ofMsg(Msg.loadSettings())
];
}
// In our update function, we first distinguish between the sources of the messages
export function update (model: Model, msg: Message): UpdateReturnType {
switch (msg.source) {
case "Composition":
// Then call the update function for the local messages
return updateComposition(model, msg);
case "LoadSettings":
// Or call the update function for the LoadSettings messages
return LoadSettings.update(model, msg);
}
}
// For the msg parameter we use the local CompositionMessage type
const updateComposition = (model: Model, msg: CompositionMessage): Elm.UpdateReturnType => {
switch (msg.name) {
case "myMessage":
return [{}];
}
}
`$3
If you use composition and thus have multiple subscriptions, you can merge them with the
mergeSubscriptions function:`ts
import { mergeSubscriptions } from "react-elmish";
import * as LoadSettings from "./LoadSettings";function localSubscription (model: Model): SubscriptionResult {
// ...
}
const subscription = mergeSubscriptions(LoadSettings.subscription, localSubscription);
`Testing
To test your update handler you can use some helper functions in
react-elmish/testing:| Function | Description |
| --- | --- |
|
initAndExecCmd | Calls the init function with the provided props and executes the returned commands. |
| getUpdateFn | Returns an update function for your update map object. |
| getUpdateAndExecCmdFn | Returns an update function for your update map object, which immediately executes the command. |
| getConsecutiveUpdateFn | Returns an update function for your update map object, which runs all consecutive commands until only the model gets updated or nothing happens. |
| getCreateUpdateArgs | Creates a factory function to create a message, a model, and props in a test. |
| createUpdateArgsFactory | This is an alternative for getCreateUpdateArgs. Creates a factory function to create a message, a model, and props in a test. |
| execCmd | Executes the provided command and returns an array of all messages. |$3
`ts
import { initAndExecCmd } from "react-elmish/testing";
import { init, Msg } from "./MyComponent";it("initializes the model correctly", async () => {
// arrange
const props = { / Create initial props / };
// act
const [model, messages] = await initAndExecCmd(init, props);
// assert
expect(model).toStrictEqual({ / what you expect in the model / });
expect(messages).toEqual([Msg.loadData()]);
});
`$3
Note: When using an
UpdateMap, you can get an update function by calling getUpdateFn:`ts
import { getUpdateFn } from "react-elmish/testing";
import { updateMap } from "./MyComponent";const updateFn = getUpdateFn(updateMap);
// Call the update function in the test
const [model, cmd] = updateFn(msg, model, props);
`A simple test:
`ts
import { getCreateUpdateArgs, createUpdateArgsFactory, execCmd } from "react-elmish/testing";
import { init, Msg } from "./MyComponent";const createUpdateArgs = getCreateUpdateArgs(init, () => ({ / initial props / }));
// Or: const createUpdateArgs = createUpdateArgsFactory(() => ({ / initial model / }), () => ({ / initial props / }));
it("returns the correct model and cmd", async () => {
// arrange
const args = createUpdateArgs(Msg.test(), { / optionally override model here / }, { / optionally override props here / }, { / optionally override options here / });
// act
// Call the update handler
const [newModel, cmd] = updateFn(...args);
const messages = await execCmd(cmd);
// assert
expect(newModel).toStrictEqual({ / what you expect in the model / });
expect(messages).toEqual([
Msg.expectedMsg1("arg"),
Msg.expectedMsg2(),
]);
});
`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).It also resolves for
attempt functions if the called functions succeed. And it rejects for perform functions if the called functions fail.$3
There is an alternative function
getUpdateAndExecCmdFn to get the update function for an update map, which immediately invokes the command and returns the messages.`ts
import { createUpdateArgs, getUpdateAndExecCmdFn } from "react-elmish/testing";const updateAndExecCmdFn = getUpdateAndExecCmdFn(updateMap);
...
it("returns the correct cmd", async () => {
// arrange
const args = createUpdateArgs(Msg.asyncTest());
// mock function which is called when the "AsyncTest" message is handled
const functionMock = jest.fn();
// act
const [, messages] = await updateAndExecCmdFn(...args);
// assert
expect(functionMock).toBeCalled();
expect(messages).toEqual([Msg.asyncTestSuccess()])
});
...
`$3
If you want to test multiple
update functions which are called in a row, you can use the getConsecutiveUpdateFn function. This function returns an update function that runs all consecutive commands until only the model gets updated or nothing happens.You may have something like this in your
update map:"load message -> load function -> loaded message -> filter message -> filter function -> filtered message"
Then the
consecutiveUpdateFn will execute all these commands in a row and return the final model after all commands have been executed.`ts
import { getConsecutiveUpdateFn, createUpdateArgsFactory } from "react-elmish/testing";
import { init, Msg } from "./MyComponent";const createUpdateArgs = createUpdateArgsFactory(init, () => ({ / initial props / }));
const consecutiveUpdateFn = getConsecutiveUpdateFn(updateMap);
it("updates the model and executes all commands", async () => {
// arrange
const args = createUpdateArgs(Msg.load(), { / optionally override model here / }, { / optionally override props here / }, { / optionally override options here / });
// act
const newModel = await consecutiveUpdateFn(...args);
// assert
expect(newModel).toStrictEqual({ / what you expect in the model / });
});
`$3
It is almost the same as testing the
update function. You can use the getCreateModelAndProps function to create a factory for the model and the props. Then use execSubscription to execute the subscriptions:`ts
import { getCreateModelAndProps, execSubscription } from "react-elmish/testing";
import { init, Msg, subscription } from "./MyComponent";const createModelAndProps = getCreateModelAndProps(init, () => ({ / initial props / }));
it("dispatches the eventTriggered message", async () => {
// arrange
const mockDispatch = jest.fn();
const args = createModelAndProps({ / optionally override model here / }, { / optionally override props here / });
const dispose = execSubscription(subscription, mockDispatch, ...args);
// act
// Trigger events
// assert
expect(mockDispatch).toHaveBeenCalledWith(Msg.eventTriggered());
// Dispose the subscriptions if required
dispose();
});
`$3
To test UI components with a fake model you can use
renderWithModel from the Testing namespace. The first parameter is a function to render your component (e.g. with @testing-library/react). The second parameter is the fake model. The third parameter is an optional options object, where you can also pass a fake dispatch function.`tsx
import { renderWithModel } from "react-elmish/testing";
import { fireEvent, render, screen } from "@testing-library/react";it("renders the correct value", () => {
// arrange
const model: Model = { value: "It works" };
// act
renderWithModel(() => render( ), model);
// assert
expect(screen.getByText("It works")).not.toBeNull();
});
it("dispatches the correct message", async () => {
// arrange
const model: Model = { value: "" };
const mockDispatch = jest.fn();
renderWithModel(() => render( ), model, { dispatch: mockDispatch });
// act
fireEvent.click(screen.getByText("Click"));
// assert
expect(mockDispatch).toHaveBeenCalledWith({ name: "click" });
});
`This works for function components using the
useElmish hook and class components.Redux Dev Tools
If you have the Redux Dev Tools installed in your browser, you can enable support for this extension by setting the
enableDevTools option to true in the init function.`ts
import { init } from "react-elmish";init({
enableDevTools: true,
});
`Hint: You should only enable this in development mode.
Migrations
$3
- Use
Logger and Message instead of ILogger and IMessage.
- The global declaration of the Nullable type was removed, because it is unexpected for this library to declare such a type. You can declare this type for yourself if needed:
`ts
declare global {
type Nullable = T | null;
}
`$3
The signature of
useElmish has changed. It takes an options object now. Thus there is no need for the useElmishMap function. Use the new useElmish hook with an UpdateMap instead.To use the old
useElmish and useElmishMap functions, import them from the legacy namespace:`ts
import { useElmish } from "react-elmish/dist/legacy/useElmish";
import { useElmishMap } from "react-elmish/dist/legacy/useElmishMap";
`Notice: These functions are marked as deprecated and will be removed in a later release.
$3
Because the legacy
useElmish and useElmishMap have been removed, you have to convert all usages of useElmish to use the parameter object.$3
The function
createCmd has been removed. Instead, import the cmd object.The test function
getOfMsgParams has been removed. Use execCmd instead, or use the getUpdateAndExecCmdFn function and use the returned update function. To test the init function, use initAndExecCmd`.You can install a snippet extension to create common elmish boilerplate code: