MyFamilyTracker
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
A practical guide to state management in Jetpack Compose with real code examples, showing how to choose the right pattern for your app's complexity and avoid common pitfalls that lead to bugs and maintenance headaches.
On this page
State management in Jetpack Compose isn't just about keeping the UI in sync—it's about building apps that survive real-world usage and team growth. After shipping 22 apps as a solo developer, I've seen too many projects crumble under state-related bugs when complexity hits.
The problem manifests quickly: UI lags behind data changes, state gets duplicated across components, or worse, business logic leaks into composables. These issues compound as your app grows, turning simple features into debugging nightmares.
Compose introduces a paradigm shift from traditional Android views. Instead of manipulating views directly, you describe UI based on state, and Compose handles the rendering. This means state becomes the single source of truth.
State in Compose can be categorized into three types:
| State Type | Scope | Example | Management |
|---|---|---|---|
| UI State | Single Screen | TextField value, dropdown expanded | code |
| Screen State | One Navigation Destination | User profile data, loading status | ViewModel + StateFlow |
| App State | Entire Application | User session, theme preferences | Repository + DataStore |
[!TIP] Keep state as close to where it's used as possible. Local UI state shouldn't bubble up unless siblings need to coordinate.
Here's how to identify the right scope:
// Local UI State - stays in the composable
@Composable
fun ExpandableCard() {
var isExpanded by remember { mutableStateOf(false) }
Card(
onClick = { isExpanded = !isExpanded }
) {
Text(if (isExpanded) "Expanded content" else "Collapsed")
}
}As complexity grows, you need structured approaches. Here are the patterns I use in production:
For self-contained components with minimal complexity:
class UserProfileState(
val repository: UserRepository
) {
var name by mutableStateOf("")
var email by mutableStateOf("")
var isLoading by mutableStateOf(false)
private set
suspend fun loadUser(userId: String) {
isLoading = true
try {
val user = repository.getUser(userId)
name = user.name
email = user.email
} finally {
isLoading = false
}
}
}
@Composable
fun UserProfileScreen() {
val state = remember { UserProfileState(UserRepository()) }
if (state.isLoading) {
CircularProgressIndicator()
} else {
Text("Name: ${state.name}")
Text("Email: ${state.email}")
}
}This is my go-to for screens with network calls or complex business logic:
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
data class ProfileUiState(
val user: User? = null,
val isLoading: Boolean = false,
val error: String? = null
)
fun loadProfile(userId: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val user = repository.getUser(userId)
_uiState.update { it.copy(user = user, isLoading = false) }
} catch (e: Exception) {
_uiState.update { it.copy(error = e.message, isLoading = false) }
}
}
}
}
@Composable
fun ProfileScreen(
viewModel: ProfileViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
ProfileContent(uiState)
}
@Composable
private fun ProfileContent(state: ProfileUiState) {
when {
state.isLoading -> CircularProgressIndicator()
state.error != null -> Text("Error: ${state.error}")
state.user != null -> Text(state.user.name)
}
}For coordinating multiple state sources or complex interactions:
class ShoppingCartState(
private val cartRepository: CartRepository,
private val analytics: Analytics
) : ViewModel() {
private val _cartItems = MutableStateFlow<List<CartItem>>(emptyList())
private val _checkoutProgress = MutableStateFlow<CheckoutProgress>(CheckoutProgress.Idle)
val cartItems: StateFlow<List<CartItem>> = _cartItems.asStateFlow()
val checkoutProgress: StateFlow<CheckoutProgress> = _checkoutProgress.asStateFlow()
val totalAmount by cartItems.map { items ->
items.sumOf { it.price * it.quantity }
}.collectAsStateInViewModel()
init {
loadCart()
}
private fun loadCart() {
viewModelScope.launch {
cartRepository.getCartItems().collect(_cartItems)
}
}
fun addToCart(item: CartItem) {
viewModelScope.launch {
cartRepository.addItem(item)
analytics.logEvent("item_added", mapOf("item_id" to item.id))
}
}
fun checkout() {
viewModelScope.launch {
_checkoutProgress.value = CheckoutProgress.Processing
try {
cartRepository.checkout()
_checkoutProgress.value = CheckoutProgress.Success
} catch (e: Exception) {
_checkoutProgress.value = CheckoutProgress.Error(e.message ?: "Unknown error")
}
}
}
}Side effects in Compose require careful consideration. They should be lifecycle-aware and cancellable:
@Composable
fun NotificationEffect(
scope: CoroutineScope = rememberCoroutineScope(),
navigateToList: () -> Unit
) {
val context = LocalContext.current
LaunchedEffect(key1 = Unit) {
// Runs once when entering composition
doSomethingImportant()
}
DisposableEffect(key1 = "analytics") {
val job = scope.launch {
analytics.logScreenView("ProfileScreen")
}
onDispose {
job.cancel()
}
}
// Navigation effect based on state
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(uiState.isSuccess) {
if (uiState.isSuccess) {
navigateToList()
}
}
}[!WARNING] Never launch coroutines from composables without proper lifecycle awareness. Use
,codeLaunchedEffect, orcoderememberCoroutineScopescope to avoid memory leaks.codeViewModel
For one-time events like navigation or snackbars, use event channels:
@HiltViewModel
class EventViewModel @Inject constructor() : ViewModel() {
private val _events = MutableLiveData<Event<Unit>>()
val events: LiveData<Event<Unit>> = _events
fun navigateToSettings() {
_events.value = Event(Unit)
}
}
// In your activity or fragment
viewModel.events.observe(this) { event ->
event.getContentIfNotHandled()?.let {
navigateToSettings()
}
}Persisting state across app restarts is crucial for user experience:
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val dataStore: DataStore<Preferences>
) : ViewModel() {
private object PreferencesKeys {
val THEME_MODE = preferencesKey<String>("theme_mode")
val ONBOARDING_COMPLETE = preferencesKey<Boolean>("onboarding_complete")
}
val themeMode = dataStore.data
.map { preferences ->
preferences[PreferencesKeys.THEME_MODE] ?: "light"
}
.onEach { mode ->
// Apply theme changes
updateTheme(mode)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "light")
suspend fun updateTheme(mode: String) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.THEME_MODE] = mode
}
}
}mutableStateOf()LaunchedEffectSudarshan Chaudhari
AI Systems Builder / Product Engineer
Bangkok, Thailand
Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.
Related Posts
Related Apps
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Building something? Available for Android dev and QA consulting.
Work with meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read