Skip to main content
Jetpack Compose Quickstarts

Jetpack Compose State Management Made Simple: An Opolis Step-by-Step Quickstart for Time-Strapped Teams

State management in Jetpack Compose is one of those topics that looks simple on the surface but quickly unravels when your app grows beyond a single screen. Teams often start with a mutableStateOf here, a remember there, and before long they're chasing phantom recompositions, stale UI, or state that disappears on configuration change. This Opolis quickstart is written for busy Android teams who need a practical, no-nonsense guide to choosing and implementing state management patterns in Compose. We'll show you the three most common approaches, when to use each, and how to avoid the traps that waste your sprint time. 1. Who Needs to Decide and Why Now If you're reading this, you're likely on a team that has already adopted Jetpack Compose—or is about to—and you've hit the moment where simple state variables aren't enough.

State management in Jetpack Compose is one of those topics that looks simple on the surface but quickly unravels when your app grows beyond a single screen. Teams often start with a mutableStateOf here, a remember there, and before long they're chasing phantom recompositions, stale UI, or state that disappears on configuration change. This Opolis quickstart is written for busy Android teams who need a practical, no-nonsense guide to choosing and implementing state management patterns in Compose. We'll show you the three most common approaches, when to use each, and how to avoid the traps that waste your sprint time.

1. Who Needs to Decide and Why Now

If you're reading this, you're likely on a team that has already adopted Jetpack Compose—or is about to—and you've hit the moment where simple state variables aren't enough. Maybe you're seeing UI that doesn't update when data changes, or you're struggling to share state across multiple composables without passing callbacks through five layers. This is the point where a deliberate state management strategy stops being optional and becomes critical for shipping on time.

The decision affects every screen in your app, from a simple toggle button to a complex form with validation. The wrong choice early on can lead to technical debt that slows down every future feature. Conversely, a solid pattern from the start keeps your code readable, testable, and easy to refactor. We're not here to sell you on a single architecture—instead, we'll give you the criteria to decide for your specific use case.

Most teams we've seen (anonymized, of course) fall into one of two camps: those who over-engineer with StateFlows for every pixel, and those who under-engineer with local state that breaks on rotation. The sweet spot is understanding the trade-offs and matching the tool to the job. This guide will help you identify which camp you're in and give you a clear path forward.

By the end of this quickstart, you'll be able to answer three questions for any composable: Where does this state live? How does it survive configuration changes? And how do other composables observe it without causing unnecessary recompositions? Let's start by mapping the landscape.

2. The Three Approaches: Local, ViewModel, and Shared StateHolders

Jetpack Compose doesn't force you into a single state management pattern. You have three primary options, each with its own strengths and weaknesses. Understanding these is the first step to making a good decision.

Approach 1: Local State with remember and mutableStateOf

This is the simplest form: you declare a var count by remember { mutableStateOf(0) } inside a composable. It's perfect for UI-only state that doesn't need to survive recomposition or be shared. Examples include text field input, toggle switches, or animation progress. The catch: it resets on configuration change (like screen rotation) unless you use rememberSaveable. It also can't be accessed from outside the composable—no sharing across siblings or parent-child without hoisting.

Approach 2: ViewModel with StateFlow or MutableState

The standard Android architecture recommends ViewModels for state that survives configuration changes and needs to be observed by one or more composables. You expose a StateFlow (or a Compose State object) from the ViewModel, and the composable collects it. This is the go-to for screen-level state like a list of items fetched from a repository, user authentication status, or form data that must persist across rotations. The overhead is minimal for a single screen, but it can become verbose if you have many ViewModels with repetitive boilerplate.

Approach 3: Shared StateHolders or StateHoisting

For state that needs to be shared across multiple composables that don't share a ViewModel (e.g., a bottom sheet and a floating action button on the same screen), you can hoist the state to a common parent composable or use a dedicated state holder class. This pattern is more flexible but requires careful thought about lifecycle and recomposition boundaries. It's often used in custom components or when you want to keep business logic out of the ViewModel.

Each approach has a place. The mistake is using one for everything. In the next section, we'll give you a clear set of criteria to choose.

3. How to Choose: Criteria Your Team Can Use

Instead of guessing, apply these four criteria to every piece of state in your composable. Write them on a whiteboard or add them to your PR checklist.

Criteria 1: Does the state need to survive configuration changes?

If the answer is yes, you need a ViewModel (or rememberSaveable for simple primitives). If no, local state is fine. This is the most common decision point. For example, a search query typed by the user should survive rotation—use ViewModel. A temporary animation progress that resets on rotation is fine as local state.

Criteria 2: Is the state shared across multiple composables that are not in a direct parent-child relationship?

If yes, you need either a ViewModel (if they share a common scope like a NavBackStackEntry) or a shared state holder hoisted to a common ancestor. If the composables are siblings or distant cousins, avoid passing state through multiple layers of parameters—that leads to fragile code. Instead, inject a shared state holder or use a ViewModel scoped to the navigation graph.

Criteria 3: Does the state trigger side effects (navigation, API calls, logging)?

Side effects should be handled in a ViewModel or a dedicated side-effect handler, not inside a composable's LaunchedEffect that depends on state. If your state change triggers a network request or a navigation event, the ViewModel is the right place to orchestrate that. Local state is for pure UI updates only.

Criteria 4: How many composables observe this state?

A single observer is fine with any approach. Multiple observers (e.g., a badge count that updates a toolbar icon and a bottom navigation label) benefit from a single source of truth in a ViewModel or shared state holder. If you duplicate state across multiple composables, you risk inconsistency and harder debugging.

Apply these criteria as a quick checklist. If you answer yes to any of the first three, lean toward ViewModel or a shared holder. If all answers are no, local state is your friend.

4. Trade-Offs at a Glance: When Each Approach Shines and Falters

No approach is perfect. Here's a structured comparison to help you weigh the trade-offs in your specific context.

Local State Pros and Cons

Pros: Zero boilerplate, easy to read, no extra classes. Cons: Lost on configuration change (unless saved), not shareable, can lead to callback chains if hoisted too far. Best for: small, isolated UI elements like a dropdown open/close state.

ViewModel with StateFlow Pros and Cons

Pros: Survives rotation, testable, integrates with Jetpack Navigation and Hilt. Cons: Requires a ViewModel class and dependency injection setup; can be overkill for trivial state; collecting flows in composables requires lifecycle awareness (using collectAsStateWithLifecycle()). Best for: screen-level state that involves business logic or data layer calls.

Shared StateHolder Pros and Cons

Pros: Flexible, keeps logic out of ViewModel, reusable across composables. Cons: Must manage lifecycle manually (e.g., clearing state when no longer needed), can introduce complexity if overused. Best for: custom components that encapsulate their own state logic, or when you need to share state between composables that don't have a common ViewModel scope.

A common pitfall is using a ViewModel for everything because it's the "official" recommendation. But if you have a simple toggle button that doesn't need to survive rotation, a ViewModel adds unnecessary files and test overhead. Conversely, using local state for a list of items fetched from a database will cause data loss on rotation and require re-fetching—a poor user experience. Match the tool to the task.

5. Implementation Path: From Prototype to Production

Once you've chosen an approach, follow this step-by-step path to implement it cleanly. We'll assume you're using ViewModel for screen-level state, as it's the most common pattern for production apps.

Step 1: Define the state in the ViewModel

Create a sealed class or data class for your UI state. For example, data class HomeUiState(val posts: List<Post> = emptyList(), val isLoading: Boolean = false). Expose it as a StateFlow from the ViewModel using MutableStateFlow. Use stateIn if you need to share across multiple collectors.

Step 2: Collect state in the composable

Use val uiState by viewModel.uiState.collectAsStateWithLifecycle() inside your composable. This ensures the collection respects lifecycle and stops when the composable leaves the screen. Avoid collectAsState() without lifecycle awareness—it can cause memory leaks or wasted recompositions.

Step 3: Handle user actions

Define functions in the ViewModel to handle events (e.g., fun onPostClicked(postId: String)). Call these from the composable via lambda parameters. Do not pass the ViewModel itself to child composables—pass lambdas or state only. This keeps your composables testable and decoupled.

Step 4: Test the state flow

Unit test the ViewModel by observing the StateFlow and verifying that state changes correctly in response to events. Use Turbine or kotlinx-coroutines-test to collect emissions in tests. This catches logic errors before they reach the UI.

Step 5: Optimize recompositions

Use derivedStateOf for computed properties, and split large composables into smaller ones with stable keys. Profile with the Compose layout inspector to ensure you're not recomposing more than necessary. If a composable recomposes when unrelated state changes, consider using remember with a stable key or hoisting state higher.

This path works for most screens. For shared state across multiple screens, consider using a shared ViewModel scoped to a navigation graph, or a repository pattern that provides reactive data.

6. Risks When You Choose Wrong or Skip Steps

State management mistakes are not just theoretical—they cause real bugs that waste developer time and frustrate users. Here are the most common risks and how to avoid them.

Risk 1: Over-observation and Cascading Recompositions

If you observe a high-level state object that changes frequently, every composable that reads it will recompose, even if they only need a small part. For example, observing the entire HomeUiState in a composable that only needs the isLoading flag will cause unnecessary recompositions when posts changes. Mitigation: break state into smaller, focused state objects, or use derivedStateOf to create a derived state that only updates when the specific field changes.

Risk 2: State Inconsistency from Multiple Sources

When the same data is held in two places (e.g., a ViewModel and a local remember), they can drift out of sync. This happens when you pass initial data to a composable and then mutate it locally without updating the source. Mitigation: keep a single source of truth. If a composable needs to modify state, the mutation should flow back to the source (via a callback or ViewModel function), not be stored locally.

Risk 3: Lifecycle Leaks and Memory Issues

Collecting a flow without lifecycle awareness can cause the collection to continue even after the composable is off-screen, leading to wasted resources or crashes. This is especially dangerous with StateFlow that emits frequently. Mitigation: always use collectAsStateWithLifecycle() from the lifecycle-runtime-compose library. For ViewModel scopes, use viewModelScope to cancel coroutines automatically.

Risk 4: Over-Engineering for Simple Cases

Creating a ViewModel, a sealed class, and a repository for a simple toggle switch adds unnecessary complexity. This slows down development and makes the codebase harder to navigate. Mitigation: apply the criteria from section 3. If the state is truly local and ephemeral, keep it simple. You can always refactor later when requirements grow.

Being aware of these risks helps you make better decisions upfront. The goal is not to avoid all complexity, but to add complexity only where it pays off.

7. Mini-FAQ: Common Questions Teams Ask

We've collected the questions that come up most often in team discussions. Here are direct answers without the fluff.

Should I use mutableStateOf or StateFlow in a ViewModel?

Both work. mutableStateOf is simpler and Compose-native, but it doesn't integrate with Kotlin flows if you need to combine multiple sources. StateFlow is more flexible for complex scenarios and is the recommended approach for new projects. Use mutableStateOf if you have a single, simple state that doesn't need flow operators.

How do I handle one-shot events (like showing a snackbar)?

One-shot events are tricky because state is persistent. Use a Channel or SharedFlow with replay = 0 in the ViewModel, and collect it in the composable with LaunchedEffect. Be careful not to re-collect on recomposition—use a key that doesn't change. Alternatively, use the Event wrapper pattern to prevent re-consumption.

Can I use a ViewModel for every composable?

Technically yes, but it's not recommended. ViewModels are scoped to a navigation destination or activity, not to individual composables. Using a ViewModel for a small reusable component (like a custom slider) creates tight coupling and makes the component harder to reuse. Prefer state hoisting for reusable components.

How do I test composables that use ViewModels?

Use the Compose testing library and inject a fake ViewModel with controlled state. For unit tests, test the ViewModel logic separately. For UI tests, use createComposeRule() and set the state via the fake ViewModel. Avoid testing the integration of ViewModel and composable together unless necessary—it's slower and more brittle.

What about rememberSaveable vs ViewModel for surviving configuration changes?

rememberSaveable works for simple types (primitives, Parcelable, Serializable) and is great for small UI state like a checkbox value. For complex state like a list of domain objects, ViewModel is better because it avoids serialization overhead and keeps the state in memory. Use rememberSaveable for ephemeral UI state, ViewModel for business state.

8. Recommendation Recap: What to Do on Monday Morning

Here's the actionable takeaway for your team. No hype, just next steps.

First, audit your current composables. For each screen, identify every piece of state and apply the four criteria from section 3. Mark each as local, ViewModel, or shared. This exercise alone will reveal inconsistencies and opportunities to simplify.

Second, standardize on a pattern for screen-level state: ViewModel with StateFlow and collectAsStateWithLifecycle(). Write a team guideline document (or a short README) that defines when to deviate. Include the three approaches and the criteria as a decision tree.

Third, set up lint rules or code review checks to catch common mistakes: using collectAsState() without lifecycle, passing ViewModel to child composables, or holding state in two places. Automated checks save time in the long run.

Fourth, schedule a 30-minute workshop where each team member implements a small feature using the chosen pattern. Compare the results and discuss what felt awkward. Adjust the guideline based on feedback.

Finally, remember that state management is a means to an end—shipping a reliable app users love. Don't let perfect architecture delay delivery. Start with the simplest pattern that meets your criteria, and refactor when the complexity justifies it. Your future self (and your teammates) will thank you.

Share this article:

Comments (0)

No comments yet. Be the first to comment!