A composable that owns its own mutableStateOf is a closed box. You can't preview it with a fixed value, you can't unit test it without spinning up Compose, and nothing outside it can read or set the state. Move that state up to the caller and all three problems go away at once.
#The old way
@Composable
fun SearchField() {
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search") }
)
}
#The new way
@Composable
fun SearchField(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search") },
modifier = modifier
)
}
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
val query by viewModel.query.collectAsStateWithLifecycle()
SearchField(
query = query,
onQueryChange = viewModel::onQueryChange
)
}
#Why it matters
SearchFieldis now a pure function of its parameters. Preview it with a fixed string. Drop it anywhere a query needs editing.- Where the state lives, and how long it survives, becomes the caller's decision instead of a side effect of the field's own composition lifecycle.
- Testing gets cheap. Assert on the
onQueryChangecalls the composable emits and never touch Compose's runtime.
#Gotcha
Hoisting further than necessary is its own bug. Push a single TextField's value all the way up to an activity-scoped ViewModel and every keystroke round-trips through a StateFlow, which can recompose the whole screen. Hoist only as far as the nearest common ancestor that actually needs the value. Often a plain remember { mutableStateOf() } one level up is enough.