Optimizing F# and React Integration with Elmish Store: A Guide to Efficient State Management
Tags: fsharp, react, webdev, programming
Published: 2024-01-24 10:06:22

[Elmish](https://elmish.github.io/elmish/) is a superb framework that establishes a more predictable and type-safe state in application development. When combined with `React.js`, it results in robust applications with a predictable workflow. However, as the codebase grows and features expand, a significant challenge becomes apparent:

> **Scaling Difficulty**

The consolidation of state calculation in one place, with the resultant state passed down as parameters, creates pure functions. These functions are theoretically easy to test and read due to their lack of side effects. Yet, challenges arise when components deep in the hierarchy need only a small piece of the state. For instance, consider a table row that can be expanded: passing the entire state down leads to unnecessary complexity, detracting from `UI/business` value with boilerplate code. Moreover, moving or reusing components in different parts of an application becomes cumbersome, as it involves extensive changes to state transport.

React advocates for maintaining state as close to the UI as possible, hence the popularity of hooks. The F# community has adapted to this with the [Feliz.UseElmish](https://zaid-ajaj.github.io/Feliz/#/Hooks/UseElmish) functionality, an excellent solution for managing local state. But what about global state, like backend-fetched entities? A potential solution is to use a hook within the [Context API](https://react.dev/reference/react/useContext). However, this approach leads to another issue:

> **Rerender Overhead**

React hooks offer simplicity in code and ensure that only components tied to a particular state (and their children) are rendered. However, updating Elmish's state at the top layer and passing the result down triggers re-renders not just for components with state changes, but for all components expecting any part of the model. While React's [reconciliation](https://legacy.reactjs.org/docs/reconciliation.html) algorithm minimizes unnecessary DOM mutations, a large number of components can still lead to performance issues. [React.memo](https://react.dev/reference/react/memo) can mitigate this, but wrapping every component in a memoization function isn't ideal, as partly [confirmed](https://react.dev/reference/react/memo#should-you-add-memo-everywhere) by the React team.

Let's examine a simple example illustrating this problem:

Model:
```fsharp
type Model = {
  Counter1: int
  Counter2: int
}

type Msg =
  | Increment1
  | Increment2

let init () =
  { Counter1 = 0; Counter2 = 0 }, Cmd.none

let update msg model =
  match msg with
  | Increment1 -> { model with Counter1 = model.Counter1 + 1 }, Cmd.none
  | Increment2 -> { model with Counter2 = model.Counter2 + 1 }, Cmd.none
```
View:
```fsharp
[<ReactComponent>]
let private Counter1 counter dispatch =
  Html.div [
    prop.className "border flex flex-col items-center justify-center gap-4 p-4"
    prop.children [
      Html.span [
        prop.className "text-xl"
        prop.text $"Counter 1: %i{counter}"
      ]
      Html.button [
        prop.className "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
        prop.onClick (fun _ -> Increment1 |> dispatch)
        prop.text "Increment"
      ]
    ]
  ]

[<ReactComponent>]
let private Counter2 counter dispatch =
  Html.div [
    prop.className "border flex flex-col items-center justify-center gap-4 p-4"
    prop.children [
      Html.span [
        prop.className "text-xl"
        prop.text $"Counter 2: %i{counter}"
      ]
      Html.button [
        prop.className "bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
        prop.onClick (fun _ -> Increment2 |> dispatch)
        prop.text "Increment"
      ]
    ]
  ]

[<ReactComponent>]
let private CounterSum counter1 counter2 =
  Html.div [
    prop.className "border p-4 text-xl"
    prop.text $"Counters Sum: %i{counter1 + counter2}"
  ]

[<ReactComponent>]
let private Panel counter1 counter2 dispatch =
  Html.div [
    prop.className "flex flex-col items-center gap-4 pt-16"
    prop.children [
      Html.div [
        prop.className "flex gap-4"
        prop.children [
          Counter1 counter1 dispatch
          Counter2 counter2 dispatch
        ]
      ]
      CounterSum counter1 counter2
    ]
  ]

[<ReactComponent>]
let AppView model dispatch =
  Html.div [
    prop.className "grid grid-rows-[auto_1fr_auto] min-h-screen"
    prop.children [
      Panel model.Counter1 model.Counter2 dispatch
    ]
  ]
```

Program configuration:
```fsharp
Program.mkProgram init update ViewOld.AppView
#if DEBUG
    |> Program.withConsoleTrace
    |> Program.withDebugger
#endif
    |> Program.withReactSynchronous "elmish-app"
    |> Program.runWith ()
```

We have a straightforward app with two counters, each controlled by a separate part of the state, and an additional component displaying their sum. The app functions correctly, but upon inspecting with `React DevTools` profiler, we observe that altering either counter triggers a re-render of all components.

![elmish full re-render example](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cwwlepfn5rwe1pydnqlb.gif)

Exploring similar state-management solutions like `Redux` suggests that a global store without unnecessary renders is feasible. So, the question arises:

> **How to achive this?**

A useful reference is the react-redux library, which demonstrates how to connect an external state to React using selectors. A quick review at [useSelector implementation](https://github.com/reduxjs/react-redux/blob/master/src/hooks/useSelector.ts) points to a solution in React's 18 [useSyncExternalStore](https://react.dev/reference/react/useSyncExternalStore). Numerous articles explain `useSyncExternalStore`, like [this one](https://thisweekinreact.com/articles/useSyncExternalStore-the-underrated-react-api). Its integration with external stores like `Elmish` and its ability to prevent unnecessary re-renders is crucial for us.

`UseSyncExternalStore` requires two key integrations:
- A `subscribe` function for registering component selectors and ensuring notification upon state changes.
- A `getSnapshot` function for selectors to obtain the current model state.

We need to prepare the Elmish program to expose these two functions:

```fsharp
type ElmishStore<'model, 'msg> = {
  GetModel: unit -> 'model
  Dispatch: 'msg -> unit
  Subscribe: UseSyncExternalStoreSubscribe
}

let createStore (arg: 'arg) (program: Program<'arg, 'model, 'msg, unit>) =
    let mutable state = None
    let mutable finalDispatch = None

    let dispatch msg =
        match finalDispatch with
        | Some finalDispatch -> finalDispatch msg
        | None -> failwith "You're using initial dispatch. That shouldn't happen."

    let subscribers = ResizeArray<unit -> unit>()

    let subscribe callback =
        subscribers.Add(callback)
        fun () -> subscribers.Remove(callback) |> ignore

    let mapSetState setState model dispatch =
        setState model dispatch
        let oldModel = state
        state <- Some model
        finalDispatch <- Some dispatch
        // Skip re-renders if model hasn't changed
        if not (obj.ReferenceEquals(model, oldModel)) then
            subscribers |> Seq.iter (fun callback -> callback ())

    program |> Program.map id id id mapSetState id id |> Program.runWith arg

    let getState () =
        match state with
        | Some state -> state
        | None -> failwith "State is not initialized. That shouldn't happen."

    let store =
        { GetModel = getState
          Dispatch = dispatch
          Subscribe = UseSyncExternalStoreSubscribe subscribe }

    store
```

The crucial element in this setup is the `mapSetState` function. It intercepts updated model states, notifies subscribers about potential changes, and ensures state consistency.

We then define custom hooks for selecting state snapshots:

```fsharp
[<Hook>]
static member useElmishStore(store, selector: 'model -> 'a) =
    React.useSyncExternalStore (
      store.Subscribe,
      React.useCallback (
        (fun () -> store.GetModel() |> selector),
        [| box store; box selector |]
      )
    )

[<Hook>]
static member useElmishStoreMemoized(store, selector: 'model -> 'a, isEqual: 'a -> 'a -> bool) =
    React.useSyncExternalStoreWithSelector (
      store.Subscribe,
      React.useCallback(
        (fun () -> store.GetModel()),
        [| box store; box selector |]
      ),
      selector,
      isEqual
    )
```

The `useElmishStore` function requires a store and a selector to access specific parts of the state. On the other hand, `useElmishStoreMemoized` adds an extra parameter for a custom selector comparison function. This is particularly useful when the selector returns state data that cannot be effectively compared using the default reference equality.

With this setup in place, our program configuration and store creation appear as follows:

```fsharp
module ModelStore

let store =
    Program.mkProgram init update (fun _ _ -> ())
    // custom program configuration
#if DEBUG
    |> Program.withConsoleTrace
    |> Program.withDebugger
#endif
    |> createStore ()

[<Hook>]
let useSelector (selector: Model -> 'a) = 
    React.useElmishStore (store, selector)

[<Hook>]
let useSelectorMemoized (memoizedSelector: Model -> 'a, isEqual) =
    React.useElmishStoreMemoized (store, memoizedSelector, isEqual)

let dispatch = store.Dispatch
```

We can observe that the program has a mocked rendering function. From now on, rendering control is entirely outside of Elmish, and the sole bridge between them is the `useSelector` functions.

To enable React, we need to initiate it in the standard way:

```fsharp
ReactDOM
  .createRoot(Browser.Dom.document.getElementById "elmish-app")
  .render (React.strictMode [ View.AppView() ])
```

The view is now looking quite differently:
```fsharp
[<ReactComponent>]
let private Counter1 () =
  let counter = ModelStore.useSelector (_.Counter1)

  Html.div [
    prop.className "border flex flex-col items-center justify-center gap-4 p-4"
    prop.children [
      Html.span [
        prop.className "text-xl"
        prop.text $"Counter 1: %i{counter}"
      ]
      Html.button [
        prop.className "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
        prop.onClick (fun _ -> Increment1 |> ModelStore.dispatch)
        prop.text "Increment"
      ]
    ]
  ]

[<ReactComponent>]
let private Counter2 () =
  let counter = ModelStore.useSelector (_.Counter2)

  Html.div [
    prop.className "border flex flex-col items-center justify-center gap-4 p-4"
    prop.children [
      Html.span [
        prop.className "text-xl"
        prop.text $"Counter 2: %i{counter}"
      ]
      Html.button [
        prop.className "bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
        prop.onClick (fun _ -> Increment2 |> ModelStore.dispatch)
        prop.text "Increment"
      ]
    ]
  ]

[<ReactComponent>]
let private CounterSum () =
  // we use memoized selector with custom equality function
  // cause function returns a new tuple instance on each call
  let counter1, counter2 = 
      ModelStore.useSelectorMemoized (
          (fun m -> (m.Counter1, m.Counter2)), 
          (=)
      )
  Html.div [
    prop.className "border p-4 text-xl"
    prop.text $"Counters Sum: %i{counter1 + counter2}"
  ]

[<ReactComponent>]
let private Panel () =
  Html.div [
    prop.className "flex flex-col items-center gap-4 pt-16"
    prop.children [
      Html.div [
        prop.className "flex gap-4"
        prop.children [
          Counter1()
          Counter2()
        ]
      ]
      CounterSum()
    ]
  ]

[<ReactComponent>]
let AppView () =
  Html.div [
    prop.className "grid grid-rows-[auto_1fr_auto] min-h-screen"
    prop.children [
      Panel()
    ]
  ]
```

Components utilize custom hooks to select the parts of the model they are interested in. A noteworthy example is the `CounterSum` component, which employs a memoized hook to return a new tuple reflecting the state of both counters. Notably, we use the equality operator as a custom `isEqual` function, and `Fable` ensures the correct implementation during compilation — that's the real power! Additionally, our application now appears clearer; there is no props drilling, and each component is responsible for its segment. However, there is a trade-off: we no longer have pure function components, making testing slightly more challenging.

Now, the application has more selective re-rendering:

![elmish useSelector example](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7i0bsm4mkq89n5gktqkp.gif)

For a complete example, [check out this project](https://github.com/SelectViewData/elmish-store/tree/main/src/ElmishStore.Example)

And that's it! I encourage you to try this approach with your `Elmish` applications and share your results in the comments! I'm eager to hear about your experiences.

I'm also excited to share that my company, [SelectView Data](https://selectviewdata.com/), has implemented and released this entire solution as the [Elmish.Store](https://github.com/SelectViewData/elmish-store) package. This marks the beginning of our F# open-source journey, and we plan to release more work in the future. Stay tuned and follow us on [X](https://x.com/SVDataSolutions) for updates!