Elmのinput/textarea/selectがうまく動かないときに読む記事

[Elm-jp](https://elm-lang.jp/)のDiscordでselectタグのあつかいが話題にのぼっていました。
実はinput/textarea/selectなどのユーザー入力を受け取るタグに関するあつかいは意外に奥が深く、しかしそれらについて詳しく述べられたことはあまりありません。

そこでこの記事では、input/textarea/selectなどのElmにおける適切なあつかい方を補足解説していきます。

# まずはinput

inputタグは比較的あつかいがかんたんです。
[Html.Attributes.value](https://package.elm-lang.org/packages/elm/html/latest/Html-Attributes#value)によって、初期値を設定したり、入力されている値を置き換えたりすることができます。

```elm
import Html exposing (Html)
import Html.Attributes as Attributes
import Html.Events as Events
import Html.Events.Extra as Events


view : Model -> Html Msg
view model =
    Html.div
        []
        [ Html.input
            [ Attributes.type_ "text"
            , Attributes.value model.freeText
            , Events.onChange ChangeFreeText
            ]
            []
        , Html.button
            [ Attributes.type_ "button"
            , Events.onClick ClickOverwriteFreeText
            ]
            [ Html.text "非情なる上書き"
            ]
        ]


type alias Model =
    { freeText : String
    }


initialModel : Model
initialModel =
    { freeText = "最初に表示される"
    }


type Msg
    = ChangeFreeText String
    | ClickOverwriteFreeText


update : Msg -> Model -> Model
update msg model =
    case msg of
        ChangeFreeText str ->
            { model | freeText = str }

        ClickOverwriteFreeText ->
            { model | freeText = "上書きやぎぃ" }
```

このコードの[コンパイル結果](https://ellie-app.com/m4NGcmL9GCCa1)を見てみましょう。
アプリケーションを読み込むと、最初に入力欄には「最初に表示される」と表示されます。

![input1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16345/10b70444-0c37-8be7-d45f-5b83048c5512.png)

次に「非情なる上書き」ボタンをクリックすると、入力欄の内容が上書きされ「上書きやぎぃ」に変わります。

![input2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16345/0424ed4a-3afb-80eb-66b9-0ba697377f8d.png)

この状態で入力欄をクリックして以下のように内容を書き換えてみましょう。

![input3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16345/dfa9fe2b-3136-d09a-ea2f-c7bc522772be.png)

再度「非情なる上書き」ボタンをクリックすると再び「上書きやぎぃ」に上書きされます。

![input2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16345/0424ed4a-3afb-80eb-66b9-0ba697377f8d.png)


入力欄に初期値を入れておくのは実用的なアプリにはよくあることです。たとえば個人情報の編集フォームのように、既存のデータを書き換えるような用途で役に立ちます。
また入力値の上書きも、良いUXを提供するために必要なものです。たとえば電話番号の入力欄にユーザーが入力したあと、自動的に全角数字を半角数字に上書きして書き換えてしまうような親切なアプリケーションを実現できます。謎のSIerさんがズブズブな国や地方公共団体から億単位の金をもらって作るような、「住所には全角文字のみを使用してください」なんてエラーを出してくる人権無視アプリとはレベルが違います😤

# 賢いひとがつまづくtextarea

さて、では先ほどの例のinputをtextareaに置き換えてみましょう。

でも賢いひとは知っています。textareaには`value`属性をつけてはならないのです！
[MDNのtextareaのページ](https://developer.mozilla.org/ja/docs/Web/HTML/Element/textarea)から引用します。

> <textarea> は value 属性に対応していません。

そう、`<textarea>`タグではデフォルト値を以下のように設定するのです。

```html
<textarea>
最初に表示されるよ
</textarea>
```

ではこれを踏まえて先ほどのコードを書き換えてみましょう！

```elm
view : Model -> Html Msg
view model =
    Html.div
        []
        [ Html.textarea
            [ Events.onChange ChangeFreeText
            ]
            [ Html.text model.freeText
            ]
        , Html.button
            [ Attributes.type_ "button"
            , Events.onClick ClickOverwriteFreeText
            ]
            [ Html.text "非情なる上書き"
            ]
        ]
```

HTMLの仕様に忠実にしたがい、`Attributes.value`を使わずにテキストノードとして現在の入力値を指定しました。
でも、実はこれではうまくいきません。

このコードの[コンパイル結果](https://ellie-app.com/m4NFqy76Fjfa1)を見てみましょう。
アプリケーションを読み込むと、最初に入力欄に「最初に表示される」と表示されます。これは想定通りです。

![textarea1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16345/3657e944-ad37-c65e-223f-6b48a87684ff.png)

この状態で入力欄をクリックして内容を「さくらちゃんやぎぃ」に書き換えてみます。なんと「非情なる上書き」ボタンをクリックしても「さくらちゃんやぎぃ」のままで「上書きやぎぃ」に置き換わりません！

![textarea2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16345/50b33627-4036-0729-d1b7-f8f6237dc5a8.png)

なぜでしょうか？ それは、本来`<textarea></textarea>`で囲まれた部分に入れるのはデフォルト値[^3]だからです。
実際にMDNのページには「既定のコンテンツ（英語版では "Default content"）」と表記されています。
ユーザー入力前のデフォルト値を置き換えたところで、入力内容を上書きすることはできないのです。

# textarea 推奨しない方法

ではどうするか。まずは **推奨しない** 解決策を示します。
まず推奨しない解決策を示す理由は、「 **デフォルト値** を置き換える」という原理を実感してもらうためです。

```elm
import Html.Keyed as Keyed

view : Model -> Html Msg
view model =
    Keyed.node "div"
        []
        [ ( model.freeText
          , Html.textarea
            [ Events.onChange ChangeFreeText
            ]
            [ Html.text model.freeText
            ]
          )
        , ( "button"
          , Html.button
            [ Attributes.type_ "button"
            , Events.onClick ClickOverwriteFreeText
            ]
            [ Html.text "非情なる上書き"
            ]
          )
        ]
```

まずは[コンパイル結果](https://ellie-app.com/m4NDCQ487Psa1)を試してください。想定通りに「非情なる上書き」ボタンが使えることが分かるはずです。

ではコードの解説をします。先ほどと変わったのは[`Keyed.node`](https://package.elm-lang.org/packages/elm/html/latest/Html-Keyed#node)を使っていることです。

`Keyed.node`の本来の使い方については[Elm guideのHtml.keyedのページ](https://guide.elm-lang.jp/optimization/keyed.html)をご覧ください。
簡単に説明すると、「子要素をIDで同定するノード」を作成するものです。上記のコードにおいて「子要素」とはテキストエリアと「非情なる上書き」ボタンのことです。通常の`Html.div`では、ただそれらをListの要素として渡していました。一方で`Keyed.node`には子要素をタプルで渡しています。そしてそのタプルの2つ目の要素が子要素そのもので、1つ目の要素がその子要素を同定するIDです。
このIDを手がかりに、モデルの変更前後で「この子要素はモデル変更前のあの子要素と同じだな」とElmランタイムが判断します。これによって、DOMの書き換えを最小限に抑えることができます。

今回のコードではこの`Keyed.node`の性質を応用しています。上記のコードではテキストエリアの要素を同定するIDとして、そのテキストエリアに入力された文字列を指定しています。
そうするとどうなるか。テキストエリアの内容が変わるたびにIDが変わるので、「あれ、さっきと別の要素ってこと？ じゃあ全体を書き換えなきゃね！」と、`<textarea>`のDOM全体が書き換えられるのです。全体が書き換えられれば全くあたらしい`<textarea>`が描画されるわけですから、そのデフォルト値として`<textarea></textarea>`で囲まれた値が採用されます。

このようにテキストエリアの内容を書き換えるごとにテキストエリア自体を再描画してやれば、 **デフォルト値** の上書きによって現在の入力値を上書きすることができるわけです。

でも、当然こんな方法は推奨されません。毎回毎回`<textarea>`を作成しなおすなんてあまりにもコストが大きい処理を行っているからです。

# textarea 適切な方法

上書きしたい値を **デフォルト値** として指定してもうまくいかないことがわかりました。ではどうしたらいいのでしょうか？

そこで前提知識として必要になるのが [`Attributes.attribute`](https://package.elm-lang.org/packages/elm/html/latest/Html-Attributes#attribute) と [`Attributes.property`](https://package.elm-lang.org/packages/elm/html/latest/Html-Attributes#property) のちがいです。
それぞれのドキュメントにも[リンク](https://github.com/elm/html/blob/master/properties-vs-attributes.md)が貼ってありますが、あらためて説明します。

`Attributes.attribute`は属性を指定するものです。HTMLにおける属性だと思っておけば問題ありません。
たとえば`Attributes.attribute "class" "foo"`はJavaScriptでは`domNode.setAttribute("class", "foo")`に対応します。

一方で`Attributes.property`はそのDOMノードのプロパティを直接書き換えるものです。
たとえば`Attributes.property "className" (Json.Encode.string "foo")`はJavaScriptでは`domNode.className = "foo"`に対応します。

これを踏まえて、JavaScriptでtextareaの現在の入力値を上書きする方法についてまず考えます。
textareaを操作するためのインターフェース[HTMLTextAreaElement](https://developer.mozilla.org/ja/docs/Web/API/HTMLTextAreaElement)には、`value`というプロパティが存在します。以下はMDNからの引用です。

> value 文字列: このコントロール内の生の値を取得／設定します。

ですから、このプロパティ値を上書きすれば、無事に現在のテキストエリアの入力内容を上書きできることになります。

ここで先ほどの`Attributes.property`と`Attributes.attribute`を思い出しましょう。
今回はDOMノードのプロパティ値を上書きしたいのですから、`Attributes.property`の出番です。
そして、実は`Attributes.value`は内部的に`Attributes.property`を使っているのです。
ですから、以下のように`Attributes.value`を使えば、モデルが変更されるたびに`HTMLTextAreaElement`の`value`プロパティが上書きされることになります。

なお、[`Attributes.value`](https://package.elm-lang.org/packages/elm/html/latest/Html-Attributes#value)のドキュメントには以下のような説明があります。

> Defines a default value which will be displayed in a `button`, `option`, `input`, `li`, `meter`, `progress`, or `param`.

おそらくドキュメントが間違っていて[^2]「"default value"（＝デフォルト値）をセットするもの」と説明しています。公式すら混乱していてかわいいですね！🐹
安全策をとるなら明示的に `Attributes.property "value"` を使ったほうが適切かもしれません。


```elm
view : Model -> Html Msg
view model =
    Html.div
        []
        [ Html.textarea
            [ Events.onChange ChangeFreeText
            , Attributes.value model.freeText
            ]
            []
        , Html.button
            [ Attributes.type_ "button"
            , Events.onClick ClickOverwriteFreeText
            ]
            [ Html.text "非情なる上書き"
            ]
        ]
```

[コンパイル結果](https://ellie-app.com/m4Pd8WMYtJta1)を試してみてください。今度はうまくいっているはずです🌸

振り返ると、inputの例でも`Attributes.value`を使っていました。あのときは`value` **属性** の感覚で使っていましたが、実は本当は[HTMLInputElement](https://developer.mozilla.org/ja/docs/Web/API/HTMLInputElement)の`value` **プロパティ** を上書きしていたのです🌱
見える世界が変わってきましたね！ 分かってる気になってるだけで、実は間違った理解だったのです。世界が変わるぅ〜

# 大ボス！ select

世界の謎が解明されて気持ちよくなったところで、いよいよ大ボスのselectです。
textareaで学んだことを活かして以下のように書き換えてみます。

```elm
view : Model -> Html Msg
view model =
    Html.div
        []
        [ Html.select
            [ Events.onChange ChangeChoice
            , Attributes.value model.chosenValue
            ]
            [ Html.option
                [ Attributes.attribute "value" ""
                ]
                [ Html.text "-- 好きな動物は？ --"
                ]
            , Html.option
                [ Attributes.attribute "value" "Goat"
                ]
                [ Html.text "ヤギ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Dog"
                ]
                [ Html.text "イヌ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Cat"
                ]
                [ Html.text "ネコ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Tanooookey"
                ]
                [ Html.text "タヌキ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Bird"
                ]
                [ Html.text "トリ"
                ]
            ]
        , Html.button
            [ Attributes.type_ "button"
            , Events.onClick ClickOverwriteChoice
            ]
            [ Html.text "非情なる上書き"
            ]
        ]
```

`Msg`や`update`も変更する必要がありますが、重要なのは`view`なのでここでは省きます。

このコードでは[HtmlSelectElement](https://developer.mozilla.org/ja/docs/Web/API/HTMLSelectElement)の`value`プロパティを上書きすることで、選択値を上書きできるようにしています。

なお、`<option>`の`value`値には`Attributes.attribute "value"`をあえて使っています。
[HtmlOptionElement](https://developer.mozilla.org/ja/docs/Web/API/HTMLOptionElement)の`value`プロパティを上書きするように`Attributes.value`を使っても実用上は問題ないのですが、少しだけ挙動が異なります。
`Attributes.value ""`のように空文字列を指定すると、`value`プロパティを上書きすること自体を省略しているようです。[^1]
なので、`Attributes.value ""`を使った場合はDev consoleを開いて当該`<option>`の`.value`値を調べるとラベルに設定された文字列`"-- 好きな動物は？ --"`が表示されます。
`Attributes.attribute "value" ""`の場合は想定通りに`""`が表示されます。
これによる実害はおそらくないものの、念のため`Attributes.attribute "value"`を使うようにしています。

また、別の側面では`HTMLOptionElement`の`value`プロパティが`HTMLInputElement`や`HTMLTextAreaElement`や`HTMLSelectElement`の`value`プロパティとは明らかに用途が異なるという点も、さくらちゃんがoptionに`Attributes.value`を使わない理由です。
optionの`value`プロパティは「この選択肢の識別子はこれ」と指定するものです。inputなどの「入力値をこれにする」とは意味あいが全く異なります。その意味でも別の表現を使うようにしています。

さて、[コンパイル結果](https://ellie-app.com/m4Pythbd6GGa1)を試すと、どうやらうまく動いているように見えます。でも実は問題があるのです。
初期値である `initialModel` を変更して `chosenValue` を `""` から `"Goat"` に変更してみてください。
本来ならロード時に「ヤギ」が選択されているはずなのに、なぜか「-- 好きな動物は？ --」が表示されます。

![select1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16345/e1ba3995-fcc0-2cf8-6847-2981e372148e.png)

# select 完全な倒し方

selectに初期値がうまく設定されないのは、ElmのVirtual DOMのレンダリング方法が関係しています。
ElmのVirtual DOMライブラリーにさっと目を通しましたが、厳密に確認したわけではないので間違っていたら教えてください。

ElmにおいてDOMノードを作成する関数は、多くの場合以下のような型をしています。

```elm
List (Attribute msg) -> List (Html msg) -> Html msg
```

こうして宣言されたノードは、以下のような順番で実際のDOMとして描画されます。

1. 当該ノードを`document.createElement`で作成する
2. そのノードに対して、1番目の引数で渡した`List (Attribute msg)`を適用する
3. 2番目の引数で渡した`List (Html msg)`から対応するDOMノードを作成する
4. 3.の各ノードを`document.appendChild`で1.のノードに追加する

ゆえに`select`に`Attributes.value`で初期値`"Goat"`を指定した場合は以下のような挙動になります。

1. 当該ノードを`document.createElement("select")`で作成する
2. そのノードに対して、1番目の引数で渡した`List (Attribute msg)`を適用する
    * このとき、`value`プロパティ値として`"Goat"`がセットされそうになる
    * しかしこの時点ではoptionが1つも存在しておらず、`"Goat"`が存在しないIDだと判断される
    * そのため、ここでは`value`プロパティがセットされない
3. 2番目の引数で渡した`List (Html msg)`から対応するDOMノードを作成する
4. 3.の各ノードを`document.appendChild`で1.のノードに追加する
    * これによって各optionが描画されるが、`select`の`value`プロパティは既定値の`""`になっている
    * ゆえにoptionのうち`""`を`value`属性としてもつ最初の要素が選択された状態になる

これが、inputやtextareaには`value`プロパティで初期値を設定できるのにselectではうまくいかない理由です。

ですから、もし`""`以外の初期選択値を有効にしたい場合は別の方法が必要になります。
それがoptionの`selected`属性です。[MDNのoptionのページ](https://developer.mozilla.org/ja/docs/Web/HTML/Element/option)から引用します。

> selected この論理属性を設定すると、その選択肢が初期状態で選択されます。

ということで、以下のように書き換えれば初期選択値も正しく反映されます。

```elm
view : Model -> Html Msg
view model =
    Html.div
        []
        [ Html.select
            [ Events.onChange ChangeChoice
            , Attributes.value model.chosenValue
            ]
            [ Html.option
                [ Attributes.attribute "value" ""
                , Attributes.selected <| model.chosenValue == ""
                ]
                [ Html.text "-- 好きな動物は？ --"
                ]
            , Html.option
                [ Attributes.attribute "value" "Goat"
                , Attributes.selected <| model.chosenValue == "Goat"
                ]
                [ Html.text "ヤギ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Dog"
                , Attributes.selected <| model.chosenValue == "Dog"
                ]
                [ Html.text "イヌ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Cat"
                , Attributes.selected <| model.chosenValue == "Cat"
                ]
                [ Html.text "ネコ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Tanooookey"
                , Attributes.selected <| model.chosenValue == "Tanooookey"
                ]
                [ Html.text "タヌキ"
                ]
            , Html.option
                [ Attributes.attribute "value" "Bird"
                , Attributes.selected <| model.chosenValue == "Bird"
                ]
                [ Html.text "トリ"
                ]
            ]
        , Html.button
            [ Attributes.type_ "button"
            , Events.onClick ClickOverwriteChoice
            ]
            [ Html.text "非情なる上書き"
            ]
        ]
```

[コンパイル結果](https://ellie-app.com/m5X2Pw9T3Zva1)を見ると、想定通りに動いていることがわかります🌸

# まとめ

このように、elm/htmlがJSのどのような関数に対応しているか理解することで、細かな挙動をうまく制御できるようになりました。
Elmは **easy** ではないので、このようなJSやDOM要素の仕様に関する知識が必要になる場面があります。一方で **simple** なので、このような仕様さえ理解すれば、そのまま実際の挙動をうまく制御できます。

「フロントエンドはよくわからんし勉強したくないからElmを使おう」という動機だとだいたい失敗する理由もこれです。必要な知識を学びながらJSやTypeScriptを使って書くことも全く厭わない方にとっては、Elmは最良の選択肢になるはずです。
手持ちのスキルを無理やり流用して問題を解決するのではなく、「[無形のスキル](https://note.com/arowm_/n/n440fe0bda36e)」をかつようしましょう。

![eyecatch.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16345/a685f3bb-1330-3681-fa05-cb123cf03938.jpeg)

[さくらちゃんのツイッターをフォローする](https://twitter.com/arowM_)
[さくらちゃんが書いた他の記事を見る](https://qiita.com/arowM)
[さくらちゃんが翻訳したElmの本を手に入れる](https://amzn.to/3ssFWYu)
[さくらちゃんの写真集を手に入れる](https://amzn.to/38DyjWr)

[^1]: https://github.com/elm/html/issues/91
[^2]: https://github.com/elm/html/issues/249
[^3]: 「初期値」ではなく「デフォルト値」なのはどういうことなのか考えてみると、より一層理解が進みます。開発者コンソールでtextareaの`textContent`を書き換えて見てください。テキストエリアをクリックして内容を書き換える前と後では、`textContent`を書き換えたときの挙動が異なることを確認できるはずです。Elmのプログラムでも同様の挙動を示していることを確認してみてください。