第2回:State hoisting のデザインパターン集

※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原則

  1. 状態を持つのは「画面」か「ViewModel」だけに寄せる
  2. Composable は「UI とイベント」だけ担当する
  3. Single source of truth を常に意識する

これらを守るだけで、

  • 再利用性が高い
  • テストしやすい
  • リコンポーズが抑制される
  • 読みやすい
  • 設計がブレない

といったメリットが得られます。

タイトルとURLをコピーしました