In fable-elmish, how to trigger a command after the view has been rendered?

1.4k views Asked by At

I've created an app which downloads some data and plots it using Google Charts, to a div with a particular Id.

This works fine on a static page. However, when using menus to switch between multiple views, I'm struggling to redraw the chart when switching back to the appropriate view.

If I use a command to redraw the chart when the model is updated, the request fails because the particular div has not yet been rendered.

Is there a way to trigger a command when the View stage of Model-View-Update has been completed? Or a better way to redraw the chart?

Example code follows (not runnable, but should be straightforward to follow). Clicking the "Get data" button works fine, but if you then switch to the other page and back I can't find a way to draw the chart again.

module Client

open Elmish
open Elmish.React

open Fable.Helpers.React
open Fable.Helpers.React.Props

open Fulma

type View = ShowChart | NoChart
type Model = { View: View; Values: float[] option }

type Msg =
    | RequestValues
    | ReceiveValues of float[]
    | PageWithChart
    | PageWithoutChart
    | DrawChart

let init () : Model * Cmd<Msg> =
    { View = ShowChart; Values = None }, []

let update (msg : Msg) (currentModel : Model) : Model * Cmd<Msg> =
    match msg with
    | RequestValues -> currentModel, Cmd.ofMsg (ReceiveValues [| 1.0; 3.0; 2.0 |])  // normally this would be a long-running process
    | ReceiveValues values -> { currentModel with Values = Some values }, Cmd.ofMsg DrawChart
    | DrawChart ->
        match currentModel.Values with
        | Some values ->
            let series = TheGamma.Series.series<_, _>.values values
            let chart = TheGamma.GoogleCharts.chart.line series
            TheGamma.GoogleCharts.chart.show chart "Chart1"
        | None -> ()
        currentModel, []
    | PageWithChart ->
        match currentModel.Values with
        | Some _ -> { currentModel with View = ShowChart }, Cmd.ofMsg DrawChart // FAILS as the div with Id="Chart1" does not exist yet
        | None -> { currentModel with View = ShowChart }, []
    | PageWithoutChart -> { currentModel with View = NoChart }, []

let button txt onClick =
    Button.button
        [ Button.OnClick onClick ]
        [ str txt ]

let view (model : Model) (dispatch : Msg -> unit) =
    match model.View with
    | NoChart ->
        div []
          [ div [] [ str "Page without chart" ]
            button "Show page with chart" (fun _ -> dispatch PageWithChart) ]
    | ShowChart ->
        div []
          [ div [] [ str "Page with chart" ]
            button "Get data" (fun _ -> dispatch RequestValues)
            button "Show page without chart" (fun _ -> dispatch PageWithoutChart )
            div [ Id "Chart1" ] [] ]

#if DEBUG
open Elmish.Debug
open Elmish.HMR
#endif

Program.mkProgram init update view
#if DEBUG
|> Program.withConsoleTrace
|> Program.withHMR
#endif
|> Program.withReact "elmish-app"
|> Program.run

The HTML is just:

<!doctype html>
<html>
<head>
    <script type="text/javascript" src="http://www.google.com/jsapi"></script>
    <title>SAFE Template</title>
    <meta charset="utf-8">

    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.6.1/css/bulma.min.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" />
    <link href="https://fonts.googleapis.com/css?family=Open+Sans" rel="stylesheet">

    <link rel="shortcut icon" type="image/png" href="/Images/safe_favicon.png"/>
</head>
<body>
    <div id="elmish-app"></div>
    <script src="./js/bundle.js"></script>
</body>
</html>
2

There are 2 answers

0
Maxime Mangel On BEST ANSWER

You can detect when a stateless react element has been mounted by using the Ref attributes.

For example, this is what I use in my production app:

div [ Id "Chart1"
      Ref (fun element ->
          // Ref is trigger with null once for stateless element so we need to wait for the second trigger
          if not (isNull element) then
              // The div has been mounted check if this is the first time
              if model.IsJustLoaded then
                  // This is the first time, we can trigger a draw
                  dispatch DrawChart 
          )
 ]
    [ ]

Another way to go, would be to transform the div [ Id "Chart1" ] [ ] into a stateful react component and trigger a draw when componentDidMount has been called.

2
rmunn On

It's a bit hard to give specific suggestions without seeing your code, but here's a general idea of how you might handle it. In your shoes, I'd probably use the Promise implementation from the Fable Powerpack, and set it up like this:

  • The chart-drawing code doesn't draw the chart yet, it just returns a promise that, when resolved, will draw the chart.
  • Immediately after the div in the view function, use this hack to trigger some Javascript code that will resolve the promise, firing the draw() event. (Or, as suggested in another answer to that question, put that code in a script element at the bottom of your view so that its execution doesn't block anything else from rendering, then inside the script is where you'd resolve that promise).

Sorry this is so vague; if I saw your code, I might be able to make more specific suggestions. In particular, I don't know how best to handle passing that promise around: storing it in the model seems like a bad idea (storing something mutable in an immutable data model... yikes), but I haven't come up with a better idea yet. But something promise-related seems like it will be the best way to handle this situation.