28
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ElmAdvent Calendar 2019

Day 4

[Elm] Msg に更新後の値を含めるのは要注意

Last updated at Posted at 2019-12-03

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 シンプルにしたくね?」と思いつき、IncrementDecrementUpdate 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 がいつ更新されるのか(特に古い値への参照を握っていないか)**に注意しましょう!

28
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?