Skip to main content

guides/state-management.md

# State Management

Dialupの状態管理は `session`、`assigns`、`params` の3つに分かれている。

## 3種類の状態

| フィールド | 設定者 | ライフサイクル |
|-----------|--------|----------------|
| `session` | `layout.mount/1` | WebSocketセッション全体で持続 |
| `assigns` | `page.mount/2` | ナビゲーションごとにリセット |
| `params` | フレームワーク自動 | ナビゲーションごとに更新 |

### session

`layout.mount/1` が設定する。ページ遷移をまたいで持続するため、ログインユーザー情報などを格納する。

```elixir
defmodule Dialup.App.Layout do
  use Dialup.Layout

  def mount(session) do
    user = Repo.get(User, get_user_id_from_cookie())
    {:ok, Map.put(session, :current_user, user)}
  end
end
```

レイアウトがネストしている場合、親レイアウトの結果が子レイアウトの `mount/1` に渡される。

### assigns

`page.mount/2` が設定する。ナビゲーションのたびにリセットされ、現在のページ固有の状態を保持する。

```elixir
defmodule Dialup.App.Users.Id.Page do
  use Dialup.Page

  def mount(%{"id" => id}, assigns) do
    # assigns には session の内容が入っている(current_user 等を参照可能)
    post = Posts.get!(id, assigns.current_user)
    {:ok, %{post: post}}  # 返り値が page assigns になる
  end
end
```

`mount/2` の返り値はそのまま page assigns になる(session キーを含める必要はない)。

### params

URLパラメータ(パスパラメータとクエリパラメータの両方)をフレームワークが自動的に設定する。

```elixir
# /users/123?tab=posts にアクセスした場合
def mount(params, assigns) do
  id  = params["id"]   # "123"(パスパラメータ)
  tab = params["tab"]  # "posts"(クエリパラメータ)
  ...
end
```

## テンプレートでのアクセス

render 直前に `session + assigns + params` がマージされるため、テンプレートではすべて `@` でアクセスできる。どのフィールド由来かを意識する必要はない。

```html
<p>{@current_user.name}</p>  <!-- session 由来 -->
<h1>{@post.title}</h1>       <!-- assigns 由来 -->
<p>ページ: {@params["page"]}</p>  <!-- params -->
```

## handle_event での状態更新

`handle_event/3` には `session + assigns + params` がマージされた状態が渡される。返り値も同じ形式で返す。

```elixir
def handle_event("follow", _, assigns) do
  # assigns には current_user(session由来)も post(assigns由来)も含まれる
  new_post = Posts.follow(assigns.post, assigns.current_user)
  {:update, Map.put(assigns, :post, new_post)}
end
```

フレームワークが `session_keys` を使って返り値を session と assigns に自動分割するため、開発者は意識不要。

### session を更新する

`handle_event` の返り値に session キーを含めれば session も更新できる。

```elixir
def handle_event("switch_locale", locale, assigns) do
  {:update, Map.put(assigns, :locale, locale)}
  # :locale が session_keys に含まれていれば session が更新される
end
```

## ナビゲーション時の挙動

```
/users/123 → /users/456 に遷移

session:  変化なし(current_user 等を保持)
assigns:  リセット → page.mount/2 が再実行される
params:   新しいURLのパラメータに更新
```

session をリセットしたい場合は、`handle_event` や `layout.mount` 内で明示的に行う。

### ネスト layout の session マージ

初回接続では、ルートに含まれる **すべて** の layout の `mount/1` が親→子の順で実行されます。

ページ遷移時は session 全体を保持しつつ、**遷移先パスに初めて現れる layout だけ** `mount/1`
を呼びます。各 layout が返すキーのうち、まだ session に無いものだけが `Map.put_new` で追加されます。
すでにマウント済みの layout は再実行されないため、PubSub 購読などの副作用が毎回走ることはありません。

```elixir
# 例: /app → /app/settings(settings 配下にだけ Settings.Layout がある)
# Settings.Layout.mount/1 は初回遷移時のみ実行され、:settings_theme などが追加される
```

## 状態の揮発性

assigns・session はメモリ上に保持される。以下の場合に失われる:

- WebSocket切断後、タイムアウト(デフォルト5分)経過
- サーバー再起動
- プロセスクラッシュ

永続化が必要なデータはDBに保存すること。

### セッション永続化(ETS ストア)

プロセスがタイムアウトで停止する際にセッション状態を ETS に保存し、再接続時に復元するオプション。`use Dialup` に `session_store: :ets` を指定する。

```elixir
defmodule MyApp do
  use Application

  use Dialup,
    app_dir: __DIR__ <> "/app",
    title: "My App",
    session_store: :ets  # デフォルトは :memory(永続化なし)

  @impl Application
  def start(_type, _args) do
    children = [{Dialup, app: __MODULE__, port: 4000}]
    Supervisor.start_link(children, strategy: :one_for_one)
  end
end
```

`:ets` を指定すると:

- プロセスタイムアウト時に session / assigns / path が ETS テーブルに保存される
- 同じ `session_id` で再接続すると、保存された状態から復元される
- 復元後、ETS のエントリは自動的に削除される

ETS はノード再起動で消えるため、完全な永続化が必要な場合は DB を使用すること。

## 設計指針

**session に入れるもの**: ページをまたいで共有したい状態(ログインユーザー、言語設定など)

**assigns に入れるもの**: 現在のページ固有の状態(表示中のデータ、フォーム入力、UIフラグ)

**注意**: session と assigns で同じキー名を使うと session が優先される。命名の衝突を避けること。