State Management Deep Dive
This guide provides a comprehensive understanding of the state management pattern used in this
Android starter template. The pattern centers around predictable, centralized state management
using the UiState wrapper combined with specialized update functions.
Table of Contents
- Core Concepts
- The UiState Wrapper
- Update Functions Explained
- Kotlin Context Parameters
- Complete Examples
- Advanced Patterns
- Anti-Patterns to Avoid
- Migration Guide
Core Concepts
Philosophy
The state management pattern in this template follows these principles:
- Single Source of Truth: Each screen has exactly one
UiState<ScreenData>that represents all UI state - Unidirectional Data Flow: Data flows from Repository → ViewModel → UI in one direction
- Automatic Loading & Error Handling: Loading states and errors are handled automatically by
UiState - Predictable Updates: State updates follow consistent patterns using
updateState,updateStateWith, andupdateWith - Type Safety: Compile-time guarantees through Kotlin's type system
Architecture Layers
graph TB
subgraph UI["UI Layer (Compose)"]
SC[StatefulComposable<br/>- Shows LoadingIndicator<br/>- Shows ErrorSnackbar<br/>- Renders Screen]
end
subgraph VM["ViewModel Layer (MVVM)"]
VML[ViewModel<br/>- _uiState: MutableStateFlow<br/>- uiState: StateFlow<br/>- updateState()<br/>- updateStateWith()<br/>- updateWith()]
end
subgraph Data["Data Layer (Repository)"]
Repo[Repository<br/>- suspendRunCatching { }<br/>- Flow<Data>]
end
UI -->|UiState< ;ScreenData> ;|VM
VM -->|Result< ;T> ;|Data
style UI fill:#e1f5ff, stroke: #01579b, stroke-width: 2px
style VM fill: #fff3e0, stroke: #e65100,stroke-width: 2px
style Data fill: #f3e5f5,stroke: #4a148c, stroke-width: 2px
The UiState Wrapper
Definition
data class UiState<T : Any>(
val data: T,
val loading: Boolean = false,
val error: OneTimeEvent<Throwable?> = OneTimeEvent(null)
)
Purpose
UiState<T> wraps your screen data (T) and adds:
data: T: The actual screen data (always available, even during loading/error)loading: Boolean: Indicates if an async operation is in progresserror: OneTimeEvent<Throwable?>: One-time error events that won't re-trigger on recomposition
Why Use UiState?
Without UiState (Anti-pattern):
// ❌ Multiple state flows - complex and error-prone
class BadViewModel : ViewModel() {
private val _posts = MutableStateFlow<List<Post>>(emptyList())
val posts = _posts.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error = _error.asStateFlow()
fun loadPosts() {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
try {
_posts.value = repository.getPosts()
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
}
With UiState (Correct pattern):
// ✅ Single state flow - simple and predictable
data class PostsScreenData(
val posts: List<Post> = emptyList()
)
class GoodViewModel @Inject constructor(
private val repository: PostsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState(PostsScreenData()))
val uiState = _uiState.asStateFlow()
fun loadPosts() {
_uiState.updateStateWith {
repository.getPosts().map { posts ->
copy(posts = posts)
}
}
}
}
Update Functions Explained
There are three update functions, each designed for a specific use case:
| Function | Use Case | Returns New Data? | Async? |
|---|---|---|---|
updateState |
Synchronous state changes | No | No |
updateStateWith |
Async operations that return new data | Yes | Yes |
updateWith |
Async operations without new data (side effects) | No | Yes |
1. updateState - Synchronous Updates
Use when: Updating state based on current data (no async operations)
/**
* Synchronous state update function.
* Use this when you need to update the screen data based on current state,
* without performing any asynchronous operations.
*/
fun updateValue(newValue: String) {
_uiState.updateState {
copy(searchQuery = newValue)
}
}
Example - Form Input:
data class FormScreenData(
val name: String = "",
val email: String = "",
val isValid: Boolean = false
)
class FormViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState(FormScreenData()))
val uiState = _uiState.asStateFlow()
fun onNameChanged(name: String) {
_uiState.updateState {
copy(
name = name,
isValid = name.isNotBlank() && email.isNotBlank()
)
}
}
fun onEmailChanged(email: String) {
_uiState.updateState {
copy(
email = email,
isValid = name.isNotBlank() && email.isNotBlank()
)
}
}
}
2. updateStateWith - Async with New Data
Use when: Fetching data from repository/network and updating state with the result
/**
* Async state update function that returns new data.
* Use this when you need to perform an async operation (like fetching from repository)
* and update the screen data with the result.
*/
fun loadPosts() {
_uiState.updateStateWith {
repository.getPosts().map { posts ->
copy(posts = posts)
}
}
}
How it works:
- Sets
loading = true - Executes your async block
- If success: Updates
datawith result, setsloading = false - If failure: Sets
errorwith exception, keeps existingdata, setsloading = false
Example - Loading User Profile:
data class ProfileScreenData(
val user: User? = null,
val followers: List<User> = emptyList()
)
class ProfileViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState(ProfileScreenData()))
val uiState = _uiState.asStateFlow()
fun loadProfile(userId: String) {
_uiState.updateStateWith {
userRepository.getUser(userId).map { user ->
copy(user = user)
}
}
}
fun loadFollowers(userId: String) {
_uiState.updateStateWith {
userRepository.getFollowers(userId).map { followers ->
copy(followers = followers)
}
}
}
}
3. updateWith - Async without New Data
Use when: Performing async operations that don't return data (side effects like save, delete, update)
/**
* Async operation without returning new data.
* Use this when you need to perform a side effect (like saving to database)
* that doesn't return new data to display.
*/
fun savePost(post: Post) {
_uiState.updateWith {
repository.savePost(post)
}
}
How it works:
- Sets
loading = true - Executes your async block
- If success: Sets
loading = false(data unchanged) - If failure: Sets
errorwith exception, setsloading = false
Example - Saving Settings:
data class SettingsScreenData(
val darkMode: Boolean = false,
val notifications: Boolean = true
)
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState(SettingsScreenData()))
val uiState = _uiState.asStateFlow()
fun toggleDarkMode() {
_uiState.updateState {
copy(darkMode = !darkMode)
}
_uiState.updateWith {
settingsRepository.saveDarkMode(uiState.value.data.darkMode)
}
}
fun deleteAccount() {
_uiState.updateWith {
settingsRepository.deleteAccount()
}
}
}
Kotlin Context Parameters
What Are Context Parameters?
Kotlin 2.0 introduces context parameters - a way to implicitly pass context to functions without
explicitly declaring parameters. This template uses context parameters in the update functions to
access viewModelScope.
Traditional Approach (Before Context Parameters)
// ❌ Old way - must pass viewModelScope explicitly
fun <T : Any> MutableStateFlow<UiState<T>>.updateStateWith(
viewModelScope: CoroutineScope, // Explicit parameter
transform: suspend T.() -> Result<T>
) {
viewModelScope.launch {
// ...
}
}
// Usage - verbose
_uiState.updateStateWith(viewModelScope) {
repository.getData()
}
With Context Parameters (Current Implementation)
// ✅ New way - viewModelScope is implicit
context(ViewModel) // Context parameter declaration
fun <T : Any> MutableStateFlow<UiState<T>>.updateStateWith(
transform: suspend T.() -> Result<T>
) {
viewModelScope.launch { // Accessed from context
// ...
}
}
// Usage - clean and concise
_uiState.updateStateWith {
repository.getData()
}
How It Works
- Declaration:
context(ViewModel)declares that this function requires aViewModelcontext - Access: Inside the function, you can access
viewModelScopefrom theViewModelcontext - Invocation: When calling from a
ViewModelclass, the context is automatically provided
Benefits
- Cleaner API: No need to pass
viewModelScopeevery time - Type Safety: Compiler enforces that function is only called from
ViewModel - Reduced Boilerplate: Less repetitive code
Compiler Flag
To use context parameters, the project enables the feature via compiler flag:
// In build-logic/convention/../KotlinLibraryConventionPlugin.kt
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + listOf(
"-Xcontext-receivers", // Old name
"-Xcontext-parameters" // New name in Kotlin 2.0+
)
}
Complete Examples
Example 1: Posts List Screen
// 1. Define screen data
data class PostsScreenData(
val posts: List<Post> = emptyList(),
val searchQuery: String = "",
val selectedFilter: FilterType = FilterType.ALL
)
// 2. Create ViewModel
@HiltViewModel
class PostsViewModel @Inject constructor(
private val postsRepository: PostsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState(PostsScreenData()))
val uiState = _uiState.asStateFlow()
init {
loadPosts()
}
// Load posts (async with new data)
fun loadPosts() {
_uiState.updateStateWith {
postsRepository.observePosts().first().map { posts ->
copy(posts = posts.filter(selectedFilter))
}
}
}
// Update search query (synchronous)
fun onSearchQueryChanged(query: String) {
_uiState.updateState {
copy(searchQuery = query)
}
}
// Change filter (synchronous + reload)
fun onFilterChanged(filter: FilterType) {
_uiState.updateState {
copy(selectedFilter = filter)
}
loadPosts()
}
// Delete post (async without new data)
fun deletePost(postId: String) {
_uiState.updateWith {
postsRepository.deletePost(postId)
}
}
}
// 3. Create UI
@Composable
fun PostsRoute(
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean,
viewModel: PostsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
StatefulComposable(
state = uiState,
onShowSnackbar = onShowSnackbar
) { screenData ->
PostsScreen(
posts = screenData.posts,
searchQuery = screenData.searchQuery,
onSearchQueryChanged = viewModel::onSearchQueryChanged,
onFilterChanged = viewModel::onFilterChanged,
onDeletePost = viewModel::deletePost
)
}
}
@Composable
private fun PostsScreen(
posts: List<Post>,
searchQuery: String,
onSearchQueryChanged: (String) -> Unit,
onFilterChanged: (FilterType) -> Unit,
onDeletePost: (String) -> Unit
) {
Column {
SearchBar(
query = searchQuery,
onQueryChange = onSearchQueryChanged
)
FilterChips(onFilterChanged = onFilterChanged)
LazyColumn {
items(posts) { post ->
PostCard(
post = post,
onDelete = { onDeletePost(post.id) }
)
}
}
}
}
Example 2: Form with Validation
// 1. Define screen data
data class SignUpScreenData(
val email: String = "",
val password: String = "",
val confirmPassword: String = "",
val emailError: String? = null,
val passwordError: String? = null,
val isFormValid: Boolean = false
)
// 2. Create ViewModel
@HiltViewModel
class SignUpViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState(SignUpScreenData()))
val uiState = _uiState.asStateFlow()
fun onEmailChanged(email: String) {
_uiState.updateState {
copy(
email = email,
emailError = validateEmail(email),
isFormValid = isValid(email, password, confirmPassword)
)
}
}
fun onPasswordChanged(password: String) {
_uiState.updateState {
copy(
password = password,
passwordError = validatePassword(password, confirmPassword),
isFormValid = isValid(email, password, confirmPassword)
)
}
}
fun onConfirmPasswordChanged(confirmPassword: String) {
_uiState.updateState {
copy(
confirmPassword = confirmPassword,
passwordError = validatePassword(password, confirmPassword),
isFormValid = isValid(email, password, confirmPassword)
)
}
}
fun signUp() {
_uiState.updateWith {
authRepository.signUp(email, password)
}
}
private fun validateEmail(email: String): String? {
return if (email.isBlank()) "Email is required"
else if (!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches())
"Invalid email format"
else null
}
private fun validatePassword(password: String, confirmPassword: String): String? {
return when {
password.isBlank() -> "Password is required"
password.length < 8 -> "Password must be at least 8 characters"
password != confirmPassword -> "Passwords do not match"
else -> null
}
}
private fun SignUpScreenData.isValid(
email: String,
password: String,
confirmPassword: String
): Boolean {
return validateEmail(email) == null &&
validatePassword(password, confirmPassword) == null
}
}
Advanced Patterns
Pattern 1: Multiple Async Operations
Sequential async operations:
fun loadUserData(userId: String) {
_uiState.updateStateWith {
// Load user first
userRepository.getUser(userId).flatMap { user ->
// Then load their posts
postsRepository.getUserPosts(userId).map { posts ->
copy(user = user, posts = posts)
}
}
}
}
Parallel async operations:
fun loadDashboard(userId: String) {
_uiState.updateStateWith {
// Load both in parallel
val userDeferred = async { userRepository.getUser(userId) }
val statsDeferred = async { statsRepository.getStats(userId) }
// Wait for both
val user = userDeferred.await().getOrThrow()
val stats = statsDeferred.await().getOrThrow()
Result.success(copy(user = user, stats = stats))
}
}
Pattern 2: Partial Loading States
When you need fine-grained loading indicators:
data class DashboardScreenData(
val user: User? = null,
val posts: List<Post> = emptyList(),
val isLoadingPosts: Boolean = false,
val isLoadingUser: Boolean = false
)
class DashboardViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState(DashboardScreenData()))
val uiState = _uiState.asStateFlow()
fun loadUser() {
viewModelScope.launch {
_uiState.update { it.copy(data = it.data.copy(isLoadingUser = true)) }
userRepository.getUser().onSuccess { user ->
_uiState.updateState {
copy(user = user, isLoadingUser = false)
}
}.onFailure { error ->
_uiState.update {
it.copy(
data = it.data.copy(isLoadingUser = false),
error = OneTimeEvent(error)
)
}
}
}
}
fun loadPosts() {
viewModelScope.launch {
_uiState.update { it.copy(data = it.data.copy(isLoadingPosts = true)) }
postsRepository.getPosts().onSuccess { posts ->
_uiState.updateState {
copy(posts = posts, isLoadingPosts = false)
}
}.onFailure { error ->
_uiState.update {
it.copy(
data = it.data.copy(isLoadingPosts = false),
error = OneTimeEvent(error)
)
}
}
}
}
}
Pattern 3: Optimistic Updates
Update UI immediately, rollback on failure:
fun likePost(post: Post) {
// Optimistic update
val originalLikeStatus = post.isLiked
_uiState.updateState {
copy(
posts = posts.map {
if (it.id == post.id) it.copy(isLiked = !it.isLiked)
else it
}
)
}
// Try to save to server
viewModelScope.launch {
postsRepository.toggleLike(post.id).onFailure {
// Rollback on failure
_uiState.updateState {
copy(
posts = posts.map {
if (it.id == post.id) it.copy(isLiked = originalLikeStatus)
else it
}
)
}
_uiState.update {
it.copy(error = OneTimeEvent(Exception("Failed to like post")))
}
}
}
}
Anti-Patterns to Avoid
❌ Anti-Pattern 1: Multiple StateFlows
// ❌ DON'T: Manage multiple state flows
class BadViewModel : ViewModel() {
private val _posts = MutableStateFlow<List<Post>>(emptyList())
val posts = _posts.asStateFlow()
private val _loading = MutableStateFlow(false)
val loading = _loading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error = _error.asStateFlow()
}
// ✅ DO: Use single UiState
class GoodViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState(PostsScreenData()))
val uiState = _uiState.asStateFlow()
}
❌ Anti-Pattern 2: Manual Loading Management
// ❌ DON'T: Manually manage loading states
fun loadPosts() {
viewModelScope.launch {
_uiState.update { it.copy(loading = true) }
try {
val posts = repository.getPosts().getOrThrow()
_uiState.update {
it.copy(
data = it.data.copy(posts = posts),
loading = false
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
loading = false,
error = OneTimeEvent(e)
)
}
}
}
}
// ✅ DO: Use updateStateWith - handles loading automatically
fun loadPosts() {
_uiState.updateStateWith {
repository.getPosts().map { posts ->
copy(posts = posts)
}
}
}
❌ Anti-Pattern 3: Nullable Data
// ❌ DON'T: Use nullable data in UiState
data class BadScreenData(
val user: User? = null // Nullable - forces null checks everywhere
)
// ✅ DO: Provide sensible defaults
data class GoodScreenData(
val user: User = User.EMPTY, // Non-null with empty state
val hasLoadedUser: Boolean = false // Explicit flag if needed
)
❌ Anti-Pattern 4: Direct Repository Calls in UI
// ❌ DON'T: Call repository directly from composables
@Composable
fun BadScreen(repository: PostsRepository) {
val posts = repository.observePosts()
.collectAsStateWithLifecycle(emptyList())
// UI rendering
}
// ✅ DO: Call through ViewModel
@Composable
fun GoodRoute(viewModel: PostsViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
StatefulComposable(state = uiState) { screenData ->
GoodScreen(posts = screenData.posts)
}
}
Migration Guide
From LiveData to StateFlow + UiState
Before (LiveData):
class OldViewModel : ViewModel() {
private val _posts = MutableLiveData<List<Post>>()
val posts: LiveData<List<Post>> = _posts
private val _loading = MutableLiveData(false)
val loading: LiveData<Boolean> = _loading
fun loadPosts() {
_loading.value = true
viewModelScope.launch {
try {
_posts.value = repository.getPosts()
} finally {
_loading.value = false
}
}
}
}
After (StateFlow + UiState):
data class PostsScreenData(
val posts: List<Post> = emptyList()
)
class NewViewModel @Inject constructor(
private val repository: PostsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState(PostsScreenData()))
val uiState = _uiState.asStateFlow()
fun loadPosts() {
_uiState.updateStateWith {
repository.getPosts().map { posts ->
copy(posts = posts)
}
}
}
}
From Sealed Class State to UiState
Before (Sealed Class):
sealed class ScreenState {
object Loading : ScreenState()
data class Success(val data: List<Post>) : ScreenState()
data class Error(val message: String) : ScreenState()
}
class OldViewModel : ViewModel() {
private val _state = MutableStateFlow<ScreenState>(ScreenState.Loading)
val state = _state.asStateFlow()
fun loadPosts() {
_state.value = ScreenState.Loading
viewModelScope.launch {
_state.value = try {
ScreenState.Success(repository.getPosts())
} catch (e: Exception) {
ScreenState.Error(e.message ?: "Unknown error")
}
}
}
}
After (UiState):
data class PostsScreenData(
val posts: List<Post> = emptyList()
)
class NewViewModel @Inject constructor(
private val repository: PostsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState(PostsScreenData()))
val uiState = _uiState.asStateFlow()
fun loadPosts() {
_uiState.updateStateWith {
repository.getPosts().map { posts ->
copy(posts = posts)
}
}
}
}
Summary
Key Takeaways:
- Use
UiState<T>wrapper - Centralizes loading, error, and data state - Choose the right update function:
updateStatefor synchronous updatesupdateStateWithfor async operations returning dataupdateWithfor async side effects
- Context parameters make API cleaner - No need to pass
viewModelScope - StatefulComposable handles UI boilerplate - Automatic loading and error display
- Single source of truth - One
StateFlow<UiState<ScreenData>>per screen
Further Reading
Concept Guides
- Quick Reference - Cheat sheet for state management patterns
- Architecture Overview - Understand the two-layer architecture
- Adding Features - Step-by-step implementation guide
- Navigation Deep Dive - Learn how navigation integrates with state
Module Documentation
- Core UI Module - State management utilities and UiState wrapper
- Data Layer Module - Repository patterns and data flow
- Feature Modules - Examples of state management in practice
API Documentation
-
StatefulComposable.kt- Stateful composable implementation