※ChatGPTを使用して記事を作成しています。
はじめに
Jetpack Compose を本格的に使い始めると、誰もがぶつかる壁があります。
「この状態、どこに置くべき…?」
「Composable が太りすぎる…」
「State hoisting の正しい形ってどれ?」
Compose は “状態と UI の関係が明確になる” という魅力がある一方、
状態を hoist(引き上げ)すべきタイミング が判断しづらいのも事実。
そこで今回は、実際のアプリ設計で頻出する
「これはこう分ければ迷わない」State hoisting のデザインパターン集
として整理します。
以下のような人に最適です:
- Compose の UI 設計に確信が持てない
- どの層に state を置くべきか迷うことがある
- 再利用性の高い Composable を書きたい
- ViewModel との責務分離で悩んでいる
基本原則:State hoisting の基準点
1. “状態の唯一性(Single source of truth)”
状態を複数箇所で持たない。
「どこが正とすべきか?」を常に意識する。
2. “UI は状態の結果である”
Composable は状態に従って UI を描くだけ。
変化の判断基準は外で持つ。
3. “Composable はイベントだけ外へ返す”
UI 内では状態を持たず、
onChangeX 系イベントを外側(親)に送る のが基本。
パターン1:入力フォームの基本パターン
もっとも頻出するのは TextField です。
「値を持つのか?外から受け取るのか?」で迷いがちですが、
答えは明確で…
TextField は value を外から受け取り、イベントだけ返す
失敗例(状態を内部に閉じ込めてしまう)
@Composable
fun BadInput() {
var text by remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = { text = it }
)
}
UI と状態が密結合し、
再利用不可・テスト不可・外部連携不可 の三重苦。
正しいパターン(State hoisting)
@Composable
fun GoodInput(
value: String,
onValueChange: (String) -> Unit
) {
TextField(
value = value,
onValueChange = onValueChange
)
}
呼び出し側が状態を保持する。
@Composable
fun Screen() {
var text by remember { mutableStateOf("") }
GoodInput(
value = text,
onValueChange = { text = it }
)
}
パターン2:複数項目をまとめる UI コンポーネント
フォームで複数の値をまとめる UI は多い。
例:名前入力 + メール入力をセットにした UI。
ポイント
- 子コンポーネントは「まとめられたフォーム」ではなく「個別項目」
- 親の中でまとめたデータモデルを扱う
データモデル
data class ProfileForm(
val name: String = "",
val email: String = ""
)
親(state を保持)
@Composable
fun ProfileScreen() {
var form by remember { mutableStateOf(ProfileForm()) }
ProfileFormContent(
form = form,
onNameChange = { form = form.copy(name = it) },
onEmailChange = { form = form.copy(email = it) }
)
}
子 UI(stateless)
@Composable
fun ProfileFormContent(
form: ProfileForm,
onNameChange: (String) -> Unit,
onEmailChange: (String) -> Unit
) {
Column {
GoodInput(
value = form.name,
onValueChange = onNameChange
)
GoodInput(
value = form.email,
onValueChange = onEmailChange
)
}
}
パターン3:選択 UI(ラジオボタン・チェックボックス)
選択系は UI 内部で保持してはいけません。
「どれを選んでいるか?」はアプリ全体の文脈によって変わるため。
正しいパターン
@Composable
fun ColorSelector(
current: Color,
onSelect: (Color) -> Unit
) {
Row {
listOf(Color.Red, Color.Green, Color.Blue).forEach { color ->
Box(
modifier = Modifier
.size(40.dp)
.background(color)
.clickable { onSelect(color) }
)
}
}
}
呼び出し側:
var selected by remember { mutableStateOf(Color.Red) }
ColorSelector(
current = selected,
onSelect = { selected = it }
)
パターン4:非同期処理 + ViewModel パターン
画面の状態は ViewModel が保持し、
UI は ViewModel を観察する構成が良く使われる。
ViewModel 側(state の唯一性)
class ArticleViewModel : ViewModel() {
var uiState by mutableStateOf(ArticleUiState())
private set
fun load() {
viewModelScope.launch {
val articles = repository.getArticles()
uiState = uiState.copy(
articles = articles,
isLoading = false
)
}
}
}
UI(読み取り + イベント発火のみ)
@Composable
fun ArticleScreen(
vm: ArticleViewModel = viewModel()
) {
val state = vm.uiState
ArticleList(
articles = state.articles,
isLoading = state.isLoading,
onRefresh = { vm.load() }
)
}
UI はただの“表示”と“イベントの伝達”のみ。
パターン5:State + Derived state の分離
大量の状態があると UI が無駄にリコンポーズされる。
コンピュートコストの高い変換は derivedStateOf を使う。
例:フィルタリング
@Composable
fun FilteredList(items: List<String>) {
var query by remember { mutableStateOf("") }
val filtered by remember(query, items) {
derivedStateOf {
items.filter { it.contains(query) }
}
}
Column {
GoodInput(
value = query,
onValueChange = { query = it }
)
LazyColumn {
items(filtered) {
Text(it)
}
}
}
}
derivedStateOf により、無駄な全件フィルタが走らない。
パターン6:Multi-Composable State hoisting
画面が“ヘッダー・リスト・フッター”などで分割されるケース。
ポイント
- 個別の UI 部分は stateless
- 状態は Screen 単位で保持
- イベントハンドラを Screen が受ける
例:状態を Screen に集約
@Composable
fun DashboardScreen() {
var counter by remember { mutableStateOf(0) }
var message by remember { mutableStateOf("") }
DashboardHeader(count = counter)
DashboardList(
message = message,
onMessageChange = { message = it }
)
DashboardFooter(onIncrement = { counter++ })
}
3つに分割しても「状態の唯一性」が保たれている。
パターン7:Stateful → Stateless → Slot API への進化
より柔軟な UI にしたい場合、Slot API(Composable を引数として渡す)を使うのが有効。
▼Stateless + Slot API
@Composable
fun CardContainer(
header: @Composable () -> Unit,
body: @Composable () -> Unit,
footer: @Composable () -> Unit
) {
Column {
header()
body()
footer()
}
}
State hoisting と Slot API を組み合わせると、複雑な UI でも責務が破綻しない。
まとめ:State hoisting で迷わないための3原則
- 状態を持つのは「画面」か「ViewModel」だけに寄せる
- Composable は「UI とイベント」だけ担当する
- Single source of truth を常に意識する
これらを守るだけで、
- 再利用性が高い
- テストしやすい
- リコンポーズが抑制される
- 読みやすい
- 設計がブレない
といったメリットが得られます。

