DEV Community

Łukasz Krzywizna
Łukasz Krzywizna

Posted on

Optimizing F# and React Integration with Elmish Store: A Guide to Efficient State Management

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 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. 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 algorithm minimizes unnecessary DOM mutations, a large number of components can still lead to performance issues. React.memo can mitigate this, but wrapping every component in a memoization function isn't ideal, as partly confirmed by the React team.

Let's examine a simple example illustrating this problem:

Model:

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
Enter fullscreen mode Exit fullscreen mode

View:

[<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
    ]
  ]
Enter fullscreen mode Exit fullscreen mode

Program configuration:

Program.mkProgram init update ViewOld.AppView
#if DEBUG
    |> Program.withConsoleTrace
    |> Program.withDebugger
#endif
    |> Program.withReactSynchronous "elmish-app"
    |> Program.runWith ()
Enter fullscreen mode Exit fullscreen mode

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

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 points to a solution in React's 18 useSyncExternalStore. Numerous articles explain useSyncExternalStore, like this one. 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:

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
Enter fullscreen mode Exit fullscreen mode

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:

[<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
    )
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

ReactDOM
  .createRoot(Browser.Dom.document.getElementById "elmish-app")
  .render (React.strictMode [ View.AppView() ])
Enter fullscreen mode Exit fullscreen mode

The view is now looking quite differently:

[<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()
    ]
  ]
Enter fullscreen mode Exit fullscreen mode

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

For a complete example, check out this project

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, has implemented and released this entire solution as the 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 for updates!

Top comments (0)