「Msg
が来たら値を更新する」というのが普通の Elm アーキテクチャですが、一捻りして「Msg
に更新後の値を含める」と思わぬハマり方をすることがあるので注意しましょう。タイミングによっては上手く更新されないことがあります。
例
こちら何の変哲もないカウンターです。
Counter V1
module Counter exposing (main)
import Browser
import Html exposing (..)
import Html.Events exposing (..)
main : Program () Model Msg
main =
Browser.sandbox
{ init = initialModel
, view = view
, update = update
}
type alias Model =
{ count : Int }
initialModel : Model
initialModel =
{ count = 0 }
type Msg
= Increment
| Decrement
update : Msg -> Model -> Model
update msg model =
case msg of
Increment ->
{ model | count = model.count + 1 }
Decrement ->
{ model | count = model.count - 1 }
view : Model -> Html Msg
view model =
div []
[ button [ onClick Increment ] [ text "+1" ]
, div [] [ text <| String.fromInt model.count ]
, button [ onClick Decrement ] [ text "-1" ]
]
ここで、「もっと Msg
シンプルにしたくね?」と思いつき、Increment
と Decrement
を Update Int
という一つの Msg
にまとめましたとしましょう。ここまでは特に問題ありません。
Counter V2
type Msg
= Update Int
update : Msg -> Model -> Model
update msg model =
case msg of
Update amount ->
{ model | count = model.count + amount }
view : Model -> Html Msg
view model =
div []
[ button [ onClick (Update 1) ] [ text "+1" ]
, div [] [ text <| String.fromInt model.count ]
, button [ onClick (Update -1) ] [ text "-1" ]
]
さらに、「update
もっと簡単に書きたくね?」ということで、 Msg
に更新後の値を入れてしまうことにしましょう(V3)。 UI ライブラリとしてはこの方が使い勝手が良さそうです。実際、 かつて evancz/elm-sortable-table もそうしていました。
Counter V3
type Msg
= Update Int
update : Msg -> Model -> Model
update msg model =
case msg of
Update count ->
{ model | count = count }
view : Model -> Html Msg
view model =
div []
-- ここで新しい値を計算する
[ button [ onClick (Update (model.count + 1)) ] [ text "+1" ]
, div [] [ text <| String.fromInt model.count ]
, button [ onClick (Update (model.count - 1)) ] [ text "-1" ]
]
さて、一つ前とあまり変わらないように見えますが、こちらのコードは少々危うさがあります。
何が危ういのか
問題は高速にイベントを発火させた時です。V2 と V3 の違いを明らかにするために、次の JavaScript をコンソールで実行してみましょう。
// + ボタンを 100 回押す
for (let i = 0; i < 100; i++) {
document.querySelector("button").click();
}
V2 ではカウンターの値が 100
になりますが、 V3 の方では 1
にしかなりません。なんということでしょう。
これは Virtual DOM が更新される前にイベントが 100 回発生しているためです。すると model.count
は常に 0
なので Update 1
が 100 回 update
に届くということになります。
では、Virtual DOM の更新を待つとどうでしょう。今度はクリックの直後に 20ms ずつ待ってみます。
(async () => {
for (let i = 0; i < 100; i++) {
document.querySelector("button").click();
await new Promise(resolve => setTimeout(resolve, 20));
}
})();
今度は期待通り 100
と表示されます。そんなに高速に操作する人間はいないはずですが、ヘッドレスブラウザの操作(puppeteerなど)では十分起こり得ます。
さらに update
に HTTP などの非同期処理を挟むと人間でも簡単に再現可能になります。「1秒待ってからカウンターを更新する」こちらのサンプルで確かめてみましょう。
まとめ
イベントハンドリングするときは **Virtual DOM がいつ更新されるのか(特に古い値への参照を握っていないか)**に注意しましょう!