State は宣言的に、ユーザーの操作は命令的に

 

🤔 ViewModelで「Stateの生成」と「ユーザー操作」を分けて考える

Jetpack Composeでは、UIを宣言的に書くことが当たり前になりました。

「UIをどう更新するか」を細かく指示するのではなく、

「このStateなら、UIはこう見える」

という関係をコードで表現します。

この考え方は、ViewModelにもそのまま応用できます。

私はViewModelを書くとき、次のシンプルなルールで考えると整理しやすいと思っています。

・ Stateの生成は宣言的に。
・ ユーザーの意図への反応は命令的に。

この2つを分けるだけで、ViewModelはかなり読みやすくなります。

 

🤔 State は宣言的に生成する

たとえば、Repositoryがユーザー一覧をFlowとして公開しているとします。


Repository
    │
    │ users: Flow<List<User>>
    ▼
   map
    │
    ▼
 UiState
    │
    ▼
 Compose

この場合、ViewModelではFlowからUiStateをそのまま導出できます。


class UserViewModel(
    repository: UserRepository
) : ViewModel() {

    val uiState: StateFlow<UiState> =
        repository.users
            .map { users ->
                UiState.Success(users)
            }
            .stateIn(
                viewModelScope,
                SharingStarted.WhileSubscribed(5_000),
                UiState.Loading
            )
}

ここには init がありません。

load() もありません。

ViewModel がやっているのは、

uiStaterepository.users から導出される

という関係を定義することだけです。

Repository から新しい値が流れてくれば、それに応じて uiState も変わります。

ここには「最初にこれを実行して、そのあとこれを実行する」という手順がありません。

あるのは、


users → UiState

という関係だけです。

これが「宣言的な State 生成」です。

 

🤔 命令的なコードにも、もちろん役割がある

だからといって、命令的なコードをなくす必要はありません。

たとえば「更新」ボタンを考えてみます。


fun refresh() {
    viewModelScope.launch {
        repository.refresh()
    }
}

これは自然なコードです。

ユーザーが「更新したい」と明示的に操作したからです。

処理の流れも明確です。


ユーザーがRefreshをタップ
        ↓
     refresh()
        ↓
Repositoryが更新処理を実行
        ↓
     Stateが変化

これは命令的な処理です。

そして、こういう場所こそ命令的なコードが適しています。

同じことは、たとえば次のような操作にも当てはまります。


fun retry()
fun deleteUser(id: String)
fun submit()

これらはすべて、

「ユーザーがこうしてほしい」

という意図を表しています。

そのため、命令として表現するのが自然です。

 

🤔 問題は、この2つを混ぜてしまうこと

少し気になるのは、State を生成するためだけに命令的なコードを使うケースです。

たとえば、こんな ViewModel です。


class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    val uiState: StateFlow<UiState> = ...

    init {
        load()
    }

    private fun load() {
        viewModelScope.launch {
            repository.loadUsers()
        }
    }
}

このコード自体が間違っているわけではありません。

ただ、ここで一度考えてみたいことがあります。

なぜ load() が必要なのでしょうか?

すでに uiState を宣言的に定義しているのに、その State を作るために別の命令を実行しなければなりません。

構造としては、こうなっています。



ViewModelの生成
      ↓
    init
      ↓
    load()
      ↓
 Stateが生成・更新

つまり、State を得るために「いつ load() を呼ぶか」という別の問題が発生します。

もし Repository がデータを Flow として公開できるのであれば、この命令自体が不要になるかもしれません。

 

🤔 LaunchedEffect に移しても、本質は変わらない

「それなら load() を Compose 側から呼べばいい」と考えることもできます。


@Composable
fun UserScreen(
    viewModel: UserViewModel
) {
    LaunchedEffect(Unit) {
        viewModel.load()
    }

    // UI
}

UI そのものは宣言的です。

しかし、ここでは別の命令が追加されています。

「この画面がCompositionに入ったらload()を呼ぶ」

つまり、


Screen enters Composition
          ↓
   LaunchedEffect
          ↓
       load()
          ↓
      State changes

という流れです。

もちろん、これは常に悪いわけではありません。

明示的な副作用が必要なケースでは、LaunchedEffect は適切な選択です。

ただし、

「Flowとして表現できるStateを最初に作るためだけ」

に LaunchedEffect を使っているのであれば、一度設計を見直してみる価値があります。

 

🤔 ViewModel の init も同じ視点で考える

ここで、ViewModel の init についても考えてみます。


init {
    load()
}

これは、

「ViewModelが生成されたら、この処理を開始する」

という命令です。

処理の順番は明確です。


ViewModelが生成される
        ↓
       init
        ↓
      load()
        ↓
    Stateが変化

一方、State を宣言的なパイプラインとして表現できるなら、


Repository State
       ↓
      map
       ↓
     UiState

と直接書けます。

ここでは「いつ State を作るか」をコードで指示していません。

State がどのように導出されるかだけを記述しています。

この違いは小さく見えますが、ViewModel の責務を考えるうえで重要です。

 

🤔 すべてを宣言的にする必要はない

ここが一番重要なポイントかもしれません。

この考え方は、

「ViewModelの処理を全部Flowにしよう」

という話ではありません。

たとえばユーザーが削除ボタンを押した場合、


fun deleteUser(id: String) {
    viewModelScope.launch {
        repository.deleteUser(id)
    }
}

で何も問題ありません。

ユーザーが「このユーザーを削除したい」と意図を示したので、その意図を命令的な処理として表現するのは自然です。

重要なのは、

・ すべてを宣言的にすること

ではありません。

むしろ、

・ Stateは宣言的に。
・ ユーザーの意図は命令的に。

という境界を持つことです。

境界があると、ViewModel のコードを読むときにも、

・ Stateを作っているコードなのか
・ ユーザーの操作に反応しているコードなのか

を簡単に区別できます。

 

🤔 init { load() } を疑ってみる

この考え方をすると、これまで何となく書いていたコードも少し違って見えてきます。


init {
    load()
}

あるいは、


LaunchedEffect(Unit) {
    viewModel.load()
}

を見たとき、

「初期ロードだから、とりあえずこれを書く」

ではなく、

「そもそも、この load() は必要なのだろうか?」

と考えられるようになります。

もちろん、必要な場合もあります。

外部 API を明示的に叩く必要があったり、ユーザーの操作によって処理を開始する必要があったりするなら、命令的な処理は適切です。

ただ、State そのものが Flow から自然に導出できるのであれば、State を作るためだけの load() は必要ないかもしれません。

 

🧑🏻‍💻 まとめ

宣言的プログラミングと命令的プログラミングのどちらかを選ぶ必要はありません。

ViewModelの中でも、それぞれに適した場所があります。


State Generation
      ↓
  Declarative

Flow → map → UiState


User Intent
      ↓
  Imperative

大切なのは、この2つを混ぜないことです。

State の生成は宣言的に。
ユーザーの意図への反応は命令的に。

この境界を意識すると、init { load() } LaunchedEffect { viewModel.load() } を「お決まりの初期化パターン」として使うのではなく、

本当にこの load() は必要なのか?

と考えられるようになります。

そして、ときには初期化問題を解決する最善の方法は、load() を別の場所に移動することではありません。

そもそも load() が必要な設計なのかを見直すことです。

 

🧑🏻‍💻 参考記事


Related Categories :  AndroidDevelopmemtJetpackComposeKMPKotlinKotlin Multiplatform MobileToolsTrending