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
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
]
]
Program configuration:
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.
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
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
)
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
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() ])
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()
]
]
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:
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)