Elm で複数ページのアプリを作るのにおすすめな rtfeldman/elm-spa-example の紹介

Elm Advent Calendar 2017 の 20 日目の記事です！

## 一言で言うと 
[rtfeldman/elm-spa-example](https://github.com/rtfeldman/elm-spa-example) 大変参考になるのでソースコードを読みましょう。

**elm-spa-example は Elm 0.19 のリリースに合わせて大幅に書き直されました。この記事はそれ以前のバージョンに沿った内容になっています。**

## はじめに

Elm で Todo アプリ以上のものを作ろうとしたときに、どういう構成で作ったらいいのか悩みませんか？僕はこれまで Elm でページが一つだけのアプリケーション（[天気予報アプリ](https://github.com/shuhei/elm-compare)とか[ピクセルエディタ](https://github.com/shuhei/pixelm)とか）しか書いたことがなかったので、最近仕事でページが複数あるちょっとした管理画面を作るのに [rtfeldman/elm-spa-example](https://github.com/rtfeldman/elm-spa-example) を参考にしました。検索してみたところほとんど日本語の情報がなかったので簡単に紹介してみようと思います。

- ソースコード [rtfeldman/elm-spa-example](https://github.com/rtfeldman/elm-spa-example)
- [デモ](http://rtfeldman.github.io/elm-spa-example/)
- 作者による解説 [Tour of an Open-Source Elm SPA](https://dev.to/rtfeldman/tour-of-an-open-source-elm-spa) 

いろんな言語やフレームワークである程度の大きさのアプリケーション（Medium.com のクローン）を作ろうという [Real World プロジェクト](https://github.com/gothinkster/realworld)の Elm 版です。作者の Richard Feldman さんは、Elm を本番で使っており Elm 作者の Evan さんも在籍している [NoRedInk](https://www.noredink.com/) という会社のエンジニアです。Elm を使った本番アプリケーション（十万行以上！）の開発で得た知見を各種カンファレンスや[執筆中の書籍](https://www.manning.com/books/elm-in-action)で共有しており、Elm によるアプリケーション開発の第一人者の一人といえるでしょう。

SPA (Single Page Application) という名前ですが、論理的には複数ページの構成になっています。Photoshopのようなメイン画面の上にパネルが出てきたりする 1 ページしかないアプリ用ではありません。（この ２ つを区別する用語ってあるんでしょうか？）

[作者による解説](https://dev.to/rtfeldman/tour-of-an-open-source-elm-spa) によれば

>Fair warning: This is not a gentle introduction to Elm. I built this to be something I'd like to maintain, and did not hold back. This is how I'd build this application with the full power of Elm at my fingertips.

ということで、Elm 入門向けではなく、自分が実際にメンテしたいような構成で作ってあるそうです。

## フォルダ構造

Elm のソースコードの入った [src](https://github.com/rtfeldman/elm-spa-example/tree/master/src) はこんな感じになっています。

- `Data`: 各種データ型とそれらに関連する関数や JSON Decoder/Encoder
  - `Article.elm`
  - ...
- `Page`: 各ページの `Model`, `Msg`, `init`, `update`, `view`
  - `Article.elm`
  - `Article/Editor.elm`
  - ...
- `Request`: HTTP リクエストをする関数
  - `Article.elm`
  - ...
- `Views`: 各 `Page` や `Main.elm` で使う共通の view 関数
  - `Article`
      - `Favorite.elm`
      - `Feed.elm`
  - `Article.elm`
  - ...
- `Main.elm`: エントリポイント。各 `Page` をここで切り替えます
- `Ports.elm`: port 各種
- `Route.elm`: `Route` とルーティング関連の関数
- `Util.elm`: 省略

`Data`, `Request`, `Views`, `Ports.elm`, `Util.elm` あたりは名前のとおりなので、詳細はソースコードを見てみてください。この記事ではこの構成の旨味である `Main.elm`, `Page`, `Route.elm`, `Route.elm` あたりの連携について解説しようと思います。

`Page` は記事一覧ページ(`Page/Article.elm`)や記事編集ページ（`Page/Article/Editor.elm`）などページ毎のファイルに分かれています。そして `Main.elm` がそれらをうまいこと切り替える、というのが大まかな構成です。

## ページの表示

各ページはそれぞれが Elm アプリケーションのように `Model`, `Msg`, `init`, `update`, `view`（必要なら `subscriptions` も）を持っています。以下は雰囲気がわかるように簡略化したソースコードです。

```Page/Article.elm
module Page.Article exposing (Model, Msg, init, update, view)

type alias Model = { ... }

init : Session -> Article.Slug -> Task PageLoadError Model

view : Session -> Model -> Html Msg

type Msg
    = DismissErrors
    | ToggleFavorite
    | FavoriteCompleted (Result Http.Error (Article Body))
    | ...

update : Session -> Msg -> Model -> ( Model, Cmd Msg )
```

そして `Main.elm` の `Model` はその時表示中のページの `Model` を内包しています。`PageState` が `Loaded` と `TransitioningFrom` に分かれているのは、後述するルーティングのためです。`Session` はユーザのログイン状態で、`Main.elm` がページの外に持っている唯一のグローバルな状態です。グローバルな状態の保持についても後述します。

```Main.elm
import Page.Article as Article
import Page.Article.Editor as ArticleEditor

type Page
    = Article Article.Model
    | ArticleEditor ArticleEditor.Model 
    | ...

type PageState
    = Loaded Page
    | TransitioningFrom Page

type alias Model =
    { session : Session
    , pageState : PageState
    }
```

`Msg` も各ページの `Msg` を内包しています。

```Main.elm
type Msg
    = ArticleMsg Article.Msg
    | ArticleEditorMsg ArticleEditor.Msg
    | ...
```

`view` ではその時表示中のページの `view` を呼びます。ページの `Msg` が `Main.elm` の `Msg` になるよう `Html.map` でくるんでいます。`frame` はヘッダーやフッターなどの共通部分の表示のための関数です。

```Main.elm
view : Model -> Html Msg
view model =
    case model.pageState of
        Loaded page ->
            viewPage model.session False page

        TransitioningFrom page ->
            viewPage model.session True page

viewPage : Session -> Bool -> Page -> Html Msg
viewPage session isLoading page =
    let
        frame =
            Page.frame isLoading session.user
    in
    case page of
        Article subModel ->
            Article.view session subModel
                |> frame Page.Other
                |> Html.map ArticleMsg
        ...
```

`update` ではその時表示中のページの `Msg` が来たら、そのページの `update` に渡しています。（間違ったページと `Msg` の組み合わせを無視するため、最後のパターンでワイルドカードを使っています。そのため本来必要なパターンを書き忘れてもコンパイラーが警告してくれないのが微妙なところです。何か良い方法はないのでしょうか？）

```Main.elm
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    updatePage (getPage model.pageState) model

updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg )
updatePage page msg model =
    let
        toPage toModel toMsg subUpdate subMsg subModel =
            let
                ( newModel, newCmd ) =
                    subUpdate subMsg subModel
            in
            ( { model | pageState = Loaded (toModel newModel) }, Cmd.map toMsg newCmd )
    in
        case ( msg, page ) of
            ( ArticleMsg subMsg, Article subModel ) ->
                toPage Article ArticleMsg (Article.update model.session) subMsg subModel
            ...
            ( _, _ ) ->
                ( model, Cmd.none )
```

## ページの初期化

この構成の大きな特徴の一つは、画面遷移前に API からデータを取得することです。これによって、データの欠けた不完全な状態の画面を表示するのを防ぐことができます。また、ローディングのぐるぐるや失敗した場合のエラー画面を統一的に表示できます。さらに、各ページは必要なデータがすでにあることを前提に書けるので、`Model` がすっきりします。これがないと API で取得してくるデータに `Maybe` をつける必要が出てきたりしますよね。

```Page/BadFoo.elm
type alias Model =
    { foo : Maybe Foo }

init : ( Model, Cmd msg )
init =
    ( { foo = Nothing }
    , Task.attempt FooLoaded Request.Foo.get
    )

-- 以下 view でも update でも `Maybe` を気にしながらコードを書くことに・・・。
```

`elm-spa-example` の構成では、各ページの `init` 関数が `Task PageLoadError Model` を返します。API コールが成功すれば `Ok model` が返ってきてページが表示されます。API コールが失敗すれば `Err err` が返ってきて、エラー画面が表示されます。また `Task` なので `Task.map2/map3/map4/map5` で複数の API を呼んだり、`Time.now` で現在時刻を取得したりすることもできます。

```Page/FooBar.elm
type alias Model =
    { foo : Foo
    , bar : Bar
    , timestamp : Time
    }

init : Task PageLoadError Model
init =
    let
        loadFoo =
            Request.Foo.get
                |> Task.mapError (\_ -> pageLoadError "Foo is currently unavailable.")

        loadBar =
            Request.Bar.get
                |> Task.mapError (\_ -> pageLoadError "Bar is currently unavailable.")

        getTime =
            Time.now
                -- Task.map3 を使うので Task のエラーの型を揃える必要があります。
                |> Task.mapError (\_ -> pageLoadError "Timestamp is currently unavailable")
    in
        Task.map3 Model loadFoo loadBar getTime
```

`Main.elm` が `init` 関数をどう呼ぶかは次節で。

## ページの切り替え

ルーティングには [elm-lang/navigation](https://github.com/elm-lang/navigation) と [evancz/url-parser](https://github.com/evancz/url-parser) という公式のルーティングライブラリが使われています。`Route.elm` ではそれらを使って、ページの一覧の Union タイプ（`Route`）と関連する関数が定義されています。

```Route.elm
module Route exposing (Route(..), fromLocation, href, modifyUrl)


type Route
	= Home
	| Login
	| Logout
	| Register
	| Settings
	| Article Article.Slug
	| Profile Username
	| NewArticle
	| EditArticle Article.Slug

href : Route -> Attribute msg

modifyUrl : Route -> Cmd msg

fromLocation : Location -> Maybe Route
```

`href` はその名の通り `a` タグの `href` 属性を出力するためのもので、URL を手打ちすることなく型安全に URL を作ることができます。

```Page/Article.elm
a [ Route.href (Route.Profile author.username) ]
    [ img [ UserPhoto.src author.image ] [] ]
```

さてリンクがクリックされた時の処理を追ってみましょう。`Main.elm` の `main` 関数は以下のようになっています。URL が変更された際に `SetRoute (Maybe Route)` という `Msg` が送られるわけです。

```Main.elm
main : Program Value Model Msg
main =
    Navigation.programWithFlags (Route.fromLocation >> SetRoute)
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }
```

次は `update` が呼んでいる `updatePage` という関数を見てみましょう。`SetRoute (Maybe Route)` に対しては `setRoute` という関数を呼んでいます。一緒に並んでいる `ArticleLoaded (Result PageLoadError Article.Model)` はページに必要なデータ取得完了後の `Msg` です。

```Main.elm
type Msg
    = SetRoute (Maybe Route)
    | ArticleLoaded (Result PageLoadError Article.Model)
    ...

updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg )
updatePage page msg model =
    let
        ...
    in
    case ( msg, page ) of
        ( SetRoute route, _ ) ->
            setRoute route model
        ...
        ( ArticleLoaded (Ok subModel), _ ) ->
            { model | pageState = Loaded (Article subModel) } => Cmd.none

        ( ArticleLoaded (Err error), _ ) ->
            { model | pageState = Loaded (Errored error) } => Cmd.none
        ...
```

`setRoute` では URL に対応するルートがなかった場合は Not Found なページを表示します。ルートがあった場合は対応するページの `init` 関数を呼びます。この時点ではまだ遷移前のページが表示されています（`TransitioningFrom`）。ページの `init` 関数は `Task PageLoadError Model` を返します。この `Task` は `Task.attempt` で実行され、結果は上記の `ArticleLoaded (Result PageLoadError Article.Model)` として `update` を経て `updatePage` に返ってきます。ここまで来て晴れてページが切り替わるわけです。

```Main.elm
setRoute : Maybe Route -> Model -> ( Model, Cmd Msg )
setRoute maybeRoute model =
    let
        transition toMsg task =
            { model | pageState = TransitioningFrom (getPage model.pageState) }
                => Task.attempt toMsg task

        errored =
            pageErrored model
    in
    case maybeRoute of
        Nothing ->
            { model | pageState = Loaded NotFound } => Cmd.none
        ...
        Just (Route.Article slug) ->
            transition ArticleLoaded (Article.init model.session slug)
```

## グローバルな状態管理

ここまで来るとわかるように、ページが切り替わる時にそれまでのページの `Model` は破棄されてしまいます。必要なデータは毎回 API を呼んで取り直すというアプローチです。

特定のページに留まらないグローバルなデータを持ちたい場合は `Main.elm` の `Model` で管理する必要があります。`elm-spa-example` では `Session` がそれにあたります。上の `Page/Article.elm` の例にあるように、（必要があれば）各ページの `init`, `update`, `view` に渡して使います。

ただ、基本的には各ページの `Msg` ではグローバルな状態を変更することはできません。`Main.elm` の `Msg` で行う必要があります。なので、各ページの UI の真ん中からグローバルな状態を気軽に変更できないという UI 上の制約ができてしまっています。一応 [elm-spa-example のログアウトのようにルーティングを使えば](https://github.com/rtfeldman/elm-spa-example/blob/d0d7a64d6a52f81ecbd4f0b9e09266ea1e30f787/src/Main.elm#L286-L295)できないことはありませんが、URL を変更する必要があるのでうまくいかないケースもあるでしょう。そういった場合はサーバ側にデータを置いて API で更新・取得するというのも一つの手かもしれません。

## おわりに

以上です。あまり長すぎてもアレなためはしょった部分も多いです。他にも関数の命名や UI の再利用など参考になる点が多いので、ぜひソースコードを読んでみてください。[Richard Feldman さんによる大規模 Elm アプリの作り方の発表](https://www.youtube.com/watch?v=DoA4Txr4GUs)も一緒に見てみると更に良いと思います。

職場のちょっとした管理画面で同じ構成を使ってみていることろなのですが、今のところいい感じに使えています。他にも良い構成があれば教えてください！