― Compose時代に“ViewModelが太る”問題の処方箋 ―
※ChatGPTを使用して記事を作成しています。
はじめに
Jetpack Compose を導入すると、UIの責務が大きく変わります。
- UI は「状態を描画するだけ」
- 副作用は Effect に寄せる
- イベントは Event として分離する
ここまでは理屈として分かっていても、現場ではこうなりがちです。
「UIを薄くしたら、ViewModel が全部背負ってしまった」
結果、ViewModel が
- 画面の状態管理
- 入力バリデーション
- API呼び出し
- ログ送信
- 画面遷移判断
- Snackbarの文言生成
- 画面固有の整形処理
- 日付フォーマット
まで抱えて、肥大化します。
そして数か月後、
- テスト不能
- 改修が怖い
- 影響範囲が読めない
という、いつもの地獄に戻ります。
この記事では、
- ViewModel に書くべきもの
- 書いてはいけないもの
- “太らないViewModel”の設計パターン
を 失敗例→修正版→設計原則 の順で整理します。
Compose時代の ViewModel の役割(結論)
まず結論です。
ViewModel は「UI State を生成する層」である
もう少し具体的に言うと、
- 画面に必要なデータを集める
- UI State を正規化して持つ
- UI からの Intent を受ける
- Domain層(UseCase)を呼び出す
- 結果を State / Event に変換する
これが ViewModel の仕事です。
ViewModel に書いていいもの(OK)
✅ OK1: UI State の保持
sealed class UiState {
object Loading : UiState()
data class Content(val items: List<ItemUiModel>) : UiState()
data class Error(val message: String) : UiState()
}
✅ OK2: UI Event の発行
sealed class UiEvent {
data class ShowSnackbar(val message: String) : UiEvent()
object NavigateDetail : UiEvent()
}
✅ OK3: UI からのアクションを受ける関数
fun onRetry()
fun onItemClick(id: String)
fun onPullToRefresh()
✅ OK4: UseCase 呼び出しと結果変換
- UseCase の結果を UiState に変換
- エラーを文言化して UiEvent に変換
ViewModel に書いてはいけないもの(NG)
ここが一番重要です。
❌ NG1: Composable依存(Context, NavController)
ViewModel に Context を渡したくなる瞬間がありますが、基本アウトです。
class MyViewModel(
private val context: Context // ❌
)
理由:
- テスト困難
- ライフサイクルとズレる
- DIが崩れる
❌ NG2: 画面遷移の実行そのもの
fun goDetail(navController: NavController) { // ❌
navController.navigate("detail")
}
ViewModel は「遷移してほしい」という Eventを出すだけ。
❌ NG3: UI描画のためだけの状態二重管理
val title = MutableStateFlow("")
val isButtonEnabled = MutableStateFlow(false)
この2つが同時に存在して、整合性が崩れます。
👉 Stateは正規化して1つに寄せるべきです。
❌ NG4: Viewの都合のif分岐を詰め込む
例:
- TextFieldのフォーカス
- スクロール位置
- キーボード表示
これらはUI側の責務です。
失敗例:太りすぎた ViewModel(よくある)
class SampleViewModel(
private val repository: ItemRepository
) : ViewModel() {
val title = MutableStateFlow("")
val isLoading = MutableStateFlow(false)
val errorMessage = MutableStateFlow<String?>(null)
val items = MutableStateFlow<List<Item>>(emptyList())
val showSnackbar = MutableStateFlow(false)
fun init() {
isLoading.value = true
viewModelScope.launch {
try {
val result = repository.fetch()
items.value = result
if (result.isEmpty()) {
errorMessage.value = "データがありません"
}
} catch (e: Exception) {
errorMessage.value = "通信に失敗しました"
showSnackbar.value = true
} finally {
isLoading.value = false
}
}
}
fun formatDate(date: Long): String {
return SimpleDateFormat("yyyy/MM/dd").format(Date(date))
}
}
何がダメか
- 状態が散らばっている(正規化されてない)
- SnackbarをStateにしている(イベント混入)
- エラーと空状態の意味が混線
- 日付フォーマットが混ざっている(責務逸脱)
修正版:ViewModel を「State生成器」に戻す
① UiState を正規化
sealed class UiState {
object Loading : UiState()
object Empty : UiState()
data class Content(val items: List<ItemUiModel>) : UiState()
data class Error(val message: String) : UiState()
}
② UiEvent は SharedFlow
sealed class UiEvent {
data class ShowSnackbar(val message: String) : UiEvent()
}
class SampleViewModel(
private val fetchItems: FetchItemsUseCase,
private val mapper: ItemUiMapper
) : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state = _state.asStateFlow()
private val _event = MutableSharedFlow<UiEvent>()
val event = _event.asSharedFlow()
fun load() {
viewModelScope.launch {
_state.value = UiState.Loading
runCatching { fetchItems() }
.onSuccess { items ->
if (items.isEmpty()) {
_state.value = UiState.Empty
} else {
_state.value = UiState.Content(
items = items.map(mapper::toUi)
)
}
}
.onFailure {
_state.value = UiState.Error("読み込みに失敗しました")
_event.emit(UiEvent.ShowSnackbar("通信エラーが発生しました"))
}
}
}
}
③ UI 側は State を描画し、Event を消費する
@Composable
fun SampleScreen(
viewModel: SampleViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.event.collect { event ->
when (event) {
is UiEvent.ShowSnackbar ->
snackbarHostState.showSnackbar(event.message)
}
}
}
when (state) {
UiState.Loading -> Loading()
UiState.Empty -> Empty()
is UiState.Content -> Content((state as UiState.Content).items)
is UiState.Error -> Error((state as UiState.Error).message)
}
}
ViewModel が太る原因は「境界が曖昧」だから
太る ViewModel には共通点があります。
- どこまでが UI か分からない
- どこまでが Domain か分からない
- 便利だから入れる
- 後で整理する(整理されない)
つまり、
ViewModel が“最後のゴミ箱”になっている
ViewModel を太らせない設計パターン
パターン①:Intent(UI操作)を固定する
sealed class UiAction {
object Retry : UiAction()
data class ClickItem(val id: String) : UiAction()
}
ViewModelはこれを受けるだけにすると、責務が明確になります。
パターン②:Mapper を外に出す
UI表示用の整形(例:日付)は、Mapperに寄せます。
class ItemUiMapper(
private val dateFormatter: DateFormatter
) {
fun toUi(item: Item): ItemUiModel {
return ItemUiModel(
title = item.title,
dateText = dateFormatter.format(item.createdAt)
)
}
}
ViewModel から「表示都合の整形」を消すのがコツです。
パターン③:UseCase で“業務ルール”を閉じる
- 並び替え
- フィルタ
- 制約チェック
は ViewModel ではなく UseCase に置きます。
実務チェックリスト(この線を越えたら危険)
ViewModel に次が出てきたら黄色信号です。
ContextNavControllerSimpleDateFormatToastSnackbarHostStatemutableStateOfの乱立- UI表示のための if/else の嵐
👉 その処理は「ViewModelの仕事ではない」可能性が高い。
まとめ
Compose時代の ViewModel は、
UI State を生成して、UI に渡す層
です。
- UIの都合は UI に寄せる
- 業務ルールは UseCase に寄せる
- ViewModel は “接着剤” に徹する
これができると、
- ViewModel が太らない
- 改修が怖くない
- テストが書ける
- Compose が本当に楽になる

