はじめに
Jetpack Compose でローカルな状態を保持する方法として、まず最初に覚えるのが remember です。しかし実際にアプリを作っていくと、
- 画面回転すると入力していたテキストが消える
- プロセスが再生成されると、選択していたタブが最初に戻ってしまう
- ある画面の状態を、親や兄弟のComposableと共有したい
といった課題にぶつかります。これらはそれぞれ rememberSaveable と State Hoisting(状態のホイスティング) という考え方で解決できます。本記事では、remember と rememberSaveable の使い分けに加えて、State Management の基本方針である State Hoisting についても実例ベースで整理します。
結論を先に
remember |
rememberSaveable |
|
|---|---|---|
| 生存期間 | 再コンポジション間は保持される | 再コンポジション + 画面回転 + プロセス再生成後も保持される |
| 保存先 | メモリ上のみ |
Bundle(内部的には SavedStateHandle 相当の仕組み) |
| 保存できる型 | 制限なし |
Bundle に入れられる型(プリミティブ型、Parcelable、Serializable など) |
| 独自クラスの扱い | そのまま使える |
Parcelize を付けるか mapSaver/listSaver が必要 |
| 典型用途 | アニメーションの一時状態、リストのスクロール位置(揮発してよいもの) | テキスト入力値、選択中のタブ、チェックボックスの状態(揮発してほしくないもの) |
一言でまとめると、
「消えても困らない状態」は
remember、「画面回転やプロセス再生成をまたいで残したい状態」はrememberSaveable
です。
remember: コンポジションが生きている間だけ保持する
remember は、Composable が再コンポジションされても値を保持してくれますが、画面回転やプロセスの再生成(Configuration Change や、バックグラウンドで OS にプロセスを破棄された場合)には対応していません。
実例: アコーディオンの開閉状態
@Composable
fun ExpandableSection(title: String, content: String) {
var isExpanded by remember { mutableStateOf(false) }
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { isExpanded = !isExpanded }
) {
Text(title)
Icon(
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null
)
}
if (isExpanded) {
Text(content)
}
}
}
この程度の「開いているか閉じているか」という一時的なUI状態であれば、画面回転で元に戻ってしまっても大きな問題にはなりません。むしろ rememberSaveable にするほどでもない、揮発してよい状態の典型例です。
rememberSaveable: 画面回転・プロセス再生成をまたいで保持する
一方、ユーザーが入力したテキストや選択状態のように、画面回転しただけで消えてしまうとユーザー体験を損なう状態は rememberSaveable を使います。
実例: 入力フォーム
@Composable
fun FeedbackForm() {
var comment by rememberSaveable { mutableStateOf("") }
var rating by rememberSaveable { mutableStateOf(3) }
Column {
TextField(value = comment, onValueChange = { comment = it })
Slider(value = rating.toFloat(), onValueChange = { rating = it.toInt() }, valueRange = 1f..5f)
}
}
remember の代わりに rememberSaveable に変えるだけで、画面回転してもユーザーが入力した内容が消えなくなります。内部的には Bundle に保存されるため、Int や String のようなプリミティブ型はそのまま使えます。
実例: 独自クラスを保存したい場合(Parcelize)
独自のdata classを rememberSaveable で保持したい場合、そのままでは保存できずクラッシュします。Parcelable を実装する(@Parcelize を付ける)ことで対応できます。
@Parcelize
data class FilterState(
val keyword: String,
val onlyInStock: Boolean
) : Parcelable
@Composable
fun ProductFilter() {
var filterState by rememberSaveable { mutableStateOf(FilterState("", false)) }
...
}
実例: Parcelableにできない型はSaverを使う
サードパーティのクラスなど @Parcelize を付けられない型の場合は、mapSaver や listSaver を使って「保存する形」と「復元する形」を自分で定義します。
val SelectedRangeSaver = listSaver<ClosedFloatingPointRange<Float>, Float>(
save = { listOf(it.start, it.endInclusive) },
restore = { it[0]..it[1] }
)
@Composable
fun PriceRangeFilter() {
var selectedRange by rememberSaveable(stateSaver = SelectedRangeSaver) {
mutableStateOf(0f..100f)
}
...
}
State Hoisting: 状態をどこで持つべきか
remember/rememberSaveable は「値をどう保持するか」の話でしたが、Compose の State Management ではもう一つ重要な観点があります。それが State Hoisting(状態のホイスティング) です。
State Hoisting とは、Composable内部に閉じていた状態を、呼び出し元(親)に引き上げる設計パターンのことです。具体的には、Composableが状態を直接 remember で持つのではなく、value と onValueChange のような形で外部から受け取り、呼び出し元がその状態の管理責任を持つようにします。
実例: Hoisting前(状態を内部に抱えている)
@Composable
fun SearchBar() {
var query by rememberSaveable { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it }
)
}
この形だと、SearchBar の外側(親のComposableやViewModel)からは、今どんなキーワードが入力されているかを知る術がありません。例えば「検索結果一覧」を別のComposableで表示したい場合、query を親と共有できないため実装できません。
実例: Hoisting後(状態を呼び出し元に委譲する)
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit
) {
TextField(
value = query,
onValueChange = onQueryChange
)
}
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
val query by viewModel.query.collectAsState()
Column {
SearchBar(query = query, onQueryChange = viewModel::onQueryChange)
SearchResultList(query = query) // 同じqueryを別のComposableでも利用できる
}
}
SearchBar 自体は状態を持たない Stateless Composable になり、状態の実体は呼び出し元(この例では SearchScreen、実際にはさらに ViewModel 側)に一元化されます。これにより、
-
SearchBarは再利用しやすい部品になる(どこから使っても同じ挙動) - 状態の変化を親や兄弟のComposable、ViewModelと共有できる
- テストの際に「特定のqueryを渡した場合の見た目」を簡単に検証できる
というメリットが得られます。これが Compose の公式ドキュメントでも推奨されている 「状態はできるだけ上位(呼び出し元)に持ち上げる」 という設計方針です。
remember/rememberSaveable と State Hoisting の関係
ここで重要なのは、State Hoisting をした結果、「その状態を remember するのか rememberSaveable するのか」は呼び出し元の責務になるという点です。
@Composable
fun SearchScreen() {
// 呼び出し元がrememberSaveableで持つと決めている
var query by rememberSaveable { mutableStateOf("") }
SearchBar(query = query, onQueryChange = { query = it })
}
あるいは ViewModel が状態を保持する設計であれば、remember/rememberSaveable はそもそも使わず、ViewModel の StateFlow と SavedStateHandle が代わりにその役割を担います。
class SearchViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
// プロセス再生成後も復元されるプロパティデリゲート
var query by savedStateHandle.saveable { mutableStateOf("") }
private set
fun onQueryChange(value: String) {
query = value
}
}
つまり、「どこまで状態を持ち上げるか」を決めた上で、持ち上げた先(Composableの外側なのか、ViewModelなのか)に応じて remember / rememberSaveable / SavedStateHandle のいずれを使うかを選ぶ、という順番で考えるのが実践的です。
ありがちなアンチパターン
アンチパターン1: 揮発してほしくない状態を remember で持ってしまう
@Composable
fun AgreementCheckbox() {
// 画面回転すると同意状態がリセットされてしまう
var isChecked by remember { mutableStateOf(false) }
Checkbox(checked = isChecked, onCheckedChange = { isChecked = it })
}
ユーザーが同意済みだったチェックボックスが、画面回転しただけで未チェックに戻ってしまうのは典型的な不具合です。ユーザー入力に関わる状態は基本的に rememberSaveable を検討すべきです。
解決策: remember を rememberSaveable に置き換える
@Composable
fun AgreementCheckbox() {
// 画面回転してもチェック状態が保持される
var isChecked by rememberSaveable { mutableStateOf(false) }
Checkbox(checked = isChecked, onCheckedChange = { isChecked = it })
}
アンチパターン2: 再利用したいComposableが状態を内部に抱え込んでいる
@Composable
fun RatingStars() {
var rating by remember { mutableStateOf(0) }
// このComposableの外から現在の評価値を取得できない
Row {
repeat(5) { index ->
Icon(
imageVector = if (index < rating) Icons.Filled.Star else Icons.Outlined.Star,
modifier = Modifier.clickable { rating = index + 1 },
contentDescription = null
)
}
}
}
RatingStars が状態を内部に抱えていると、「今の評価値をもとに送信ボタンを有効/無効にする」といった他のComposableとの連携ができません。rating: Int と onRatingChange: (Int) -> Unit を引数で受け取るように Hoisting することで、再利用性のある部品にできます。
解決策: 状態をHoistingして呼び出し元から受け取るようにする
@Composable
fun RatingStars(
rating: Int,
onRatingChange: (Int) -> Unit
) {
Row {
repeat(5) { index ->
Icon(
imageVector = if (index < rating) Icons.Filled.Star else Icons.Outlined.Star,
modifier = Modifier.clickable { onRatingChange(index + 1) },
contentDescription = null
)
}
}
}
@Composable
fun ReviewForm() {
var rating by rememberSaveable { mutableStateOf(0) }
Column {
RatingStars(rating = rating, onRatingChange = { rating = it })
// 同じratingを見て送信ボタンの有効/無効を判定できる
Button(onClick = { /* 送信処理 */ }, enabled = rating > 0) {
Text("送信する")
}
}
}
RatingStars はStateless Composableになったことで、呼び出し元がどこであっても同じ挙動で再利用できます。加えて、状態の保持方法(この例では rememberSaveable)を呼び出し元側で自由に選べるようになっている点にも注目してください。
まとめ
-
rememberは 消えても困らない一時的な状態(画面回転で消えてよい)に使う -
rememberSaveableは 画面回転やプロセス再生成をまたいで保持したい状態(ユーザー入力など)に使う - 独自クラスを
rememberSaveableするにはParcelizeかSaver(mapSaver/listSaver)が必要 - State Hoisting は「状態を呼び出し元に引き上げる」設計パターンで、Composableの再利用性・テスト容易性を高める
- Hoistingした結果、状態を実際に保持する場所(Composableの外側かViewModelか)に応じて
remember/rememberSaveable/SavedStateHandleを使い分ける
「この状態はどこまで生き残ってほしいか」「この状態は誰が管理責任を持つべきか」という2つの軸で考えると、remember/rememberSaveable と State Hoisting の使い分けに迷いにくくなります。