State management in Kotlin multiplatform projects is a craft that demands both architectural rigor and practical flexibility. Teams building shared code for Android, iOS, and web quickly discover that what works in a single-platform app can fracture under multiplatform constraints. This guide curates the patterns that have proven resilient in real projects, the anti-patterns that lure teams into rework, and the judgment calls that separate maintainable architectures from brittle ones. We write from the perspective of practitioners who have navigated these trade-offs, not as advocates of a single dogma.
Where Stateful Patterns Meet Multiplatform Reality
The core tension in multiplatform state management is reconciling platform-specific reactivity (SwiftUI's @State, Android's LiveData, React's useState) with shared Kotlin logic. Teams often start by wrapping each platform's primitives in expect/actual declarations, but this approach fragments state logic across codebases. The more durable path is to define a unidirectional data flow in commonMain using Kotlin's Flow and StateFlow, then map to platform UI bindings at the edges. This section explores concrete scenarios where this pattern reduces duplication and where it introduces friction.
When Shared StateFlow Becomes a Bottleneck
Consider a login screen: shared logic validates credentials, manages loading states, and exposes error messages. Using a shared ViewModel with StateFlow works well for Android and iOS when both platforms use similar lifecycle-aware collection. But iOS lacks a native coroutine scope equivalent to viewModelScope, requiring manual cancellation. Teams often bridge this with a Closeable interface or a custom scope, adding boilerplate. The pattern holds when the state graph is shallow—few screens, limited side effects—but breaks down under complex navigation or platform-specific animations that demand fine-grained control.
Sealed Classes as State Contracts
A complementary pattern is modeling UI state as a sealed class hierarchy. This forces exhaustive handling in platform code and makes impossible states unrepresentable. For example, a data-loading screen might have states: Loading, Success(data), Error(message, retryAction). Each platform then maps these to its own UI primitives. The cost is that adding a new state requires changes in all platform consumers, which is a feature, not a bug—it prevents silent omissions. Teams that skip this pattern often end up with nullable fields and implicit state machines that are hard to test.
Foundations That Are Often Misunderstood
Several foundational concepts in Kotlin's state toolkit are frequently misapplied, leading to subtle bugs and performance regressions. The most common confusion is between StateFlow and SharedFlow, and between cold and hot flows in general.
StateFlow vs. BehaviorSubject vs. LiveData
StateFlow is a state-holder observable that always has a value and emits only distinct updates. It is conflated with RxJava's BehaviorSubject, but the key difference is that StateFlow is conflated (skips equal values) and has no concept of backpressure. LiveData, on the other hand, is lifecycle-aware and automatically unsubscribes on inactive states. In multiplatform code, StateFlow is preferred because it is platform-agnostic, but teams must manually handle lifecycle in iOS by suspending collection when the view disappears. A common mistake is using StateFlow for event-like emissions (navigation, toasts), which require SharedFlow with replay=0 to avoid re-emitting stale events.
The Cost of expect/actual for State Holders
Some teams attempt to abstract state holders behind expect/actual interfaces, defining a common StateHolder interface with platform-specific implementations. This approach often leads to interface explosion and difficulty in testing, because each platform's implementation may have subtly different threading or lifecycle behavior. A more maintainable pattern is to keep state management in commonMain as pure functions and data classes, and let platform code handle subscription and disposal. This separation of concerns reduces the surface area for platform-specific bugs.
Patterns That Usually Work in Practice
After observing numerous multiplatform projects, three patterns consistently reduce defects and improve developer velocity when applied with discipline.
Unidirectional Data Flow with Reducer Functions
Modeling state transitions as pure reducer functions (currentState + event -> newState) makes state changes predictable and testable. Kotlin's data classes and copy() method make this ergonomic. The reducer is a pure function in commonMain, and side effects are handled separately via a middleware or effect handler. This pattern shines in forms and multi-step workflows where state transitions are complex. Teams that adopt this pattern report that unit tests for state logic become trivial, as they only need to assert reducer outputs.
State Machines for Complex Lifecycles
When a screen has more than three distinct states with transitions that depend on both user actions and external events, a state machine library (like KStateMachine or a custom sealed class hierarchy with transition guards) reduces bugs. For example, a payment flow with states Idle, Processing, Success, Failure, and Refunding benefits from explicit transition rules that prevent invalid moves (e.g., going from Idle to Refunding). The overhead of defining states and transitions is repaid when the product adds new states or conditions.
Coroutine Scopes as Managed Resources
In multiplatform code, managing coroutine scopes is a frequent source of leaks. The pattern that works is to define a ScopeOwner interface in commonMain with a method to create a scope tied to a lifecycle, and implement it per platform. On Android, this maps to viewModelScope; on iOS, it maps to a custom scope cancelled when the view controller deinits. This pattern requires discipline to ensure scopes are not leaked, but it is the only reliable way to prevent coroutine leaks in shared code.
Anti-Patterns That Lure Teams into Rework
Several approaches appear promising but lead to maintenance nightmares. Recognizing them early saves months of refactoring.
The Global State Registry
Using a singleton MutableStateFlow for global app state (e.g., user session, theme) is tempting for its simplicity. It quickly becomes a dependency magnet, and any change to the global state triggers recomposition across unrelated screens. Teams end up adding filters or distinctUntilChanged operators to mitigate performance, but the architectural debt remains. The alternative is to scope state to the smallest necessary component, using dependency injection to provide scoped instances.
Over-Abstraction with expect/actual for Everything
A common anti-pattern is wrapping every platform API in expect/actual declarations, including state observation mechanisms. This leads to a thick abstraction layer that must be maintained across platforms, often with subtle behavioral differences. The better approach is to keep expect/actual for truly platform-specific capabilities (e.g., file I/O, sensor data) and use common patterns for state management that can be expressed purely in Kotlin.
Ignoring the Cost of Object Allocation in State Updates
In multiplatform code, especially on iOS where Kotlin/Native's memory model differs, creating many intermediate objects during state updates can cause performance issues. Some teams use immutable state and copy on every change, which is idiomatic but can be expensive in tight loops or frequent updates. Profiling often reveals that a mutable state holder with a snapshot mechanism is more performant. The key is to measure before optimizing, but awareness of allocation overhead prevents surprise regressions.
Maintenance, Drift, and Long-Term Costs
Stateful patterns incur ongoing costs that are often underestimated at the start. Understanding these costs helps teams decide whether to invest in a formal pattern or keep things simple.
Pattern Drift Across Teams
In organizations with multiple feature teams, each team may interpret the shared pattern differently. One team uses sealed classes for state, another uses nullable fields, and a third uses a custom enum. This drift makes cross-team navigation and code reviews harder. The cost is not just inconsistency but also the mental overhead of switching between paradigms. Mitigation requires a living style guide and automated lint rules that enforce the chosen pattern.
Testing Overhead for Side Effects
Patterns that separate state from side effects (e.g., using a middleware layer) make state logic testable but shift complexity to side effect testing. Testing that a side effect (e.g., API call, navigation) occurs correctly often requires mock-heavy tests or integration tests. Teams that adopt a reducer pattern should also invest in a test utility that can assert on emitted effects. Without this, side effects become untested and brittle.
Evolution of Multiplatform Conventions
As Kotlin Multiplatform matures, conventions around state management are still evolving. Libraries like KMP-NativeCoroutines and SKIE improve iOS interop, but the landscape shifts. Teams that lock into a specific pattern may face migration costs when better abstractions emerge. A pragmatic approach is to encapsulate state management behind a thin interface that can be reimplemented without changing business logic.
When Not to Use Formal State Patterns
Not every screen needs a state machine or a unidirectional data flow. Recognizing when simplicity trumps architecture is a mark of engineering maturity.
Trivial Screens with One or Two States
A simple toggle button or a static list that rarely changes does not benefit from a sealed class hierarchy or a reducer. Using a plain MutableStateFlow with a single update function is sufficient. The overhead of defining states, events, and reducers adds no value and slows down development. Teams should resist the urge to apply the same pattern uniformly.
Prototypes and Short-Lived Features
During rapid prototyping or for features with a short expected lifespan (e.g., a promotional banner), investing in a formal state pattern is wasteful. A simple observable holder with inline updates is faster to write and easier to discard. The key is to recognize when the code will be thrown away and avoid premature architectural investment.
Platform-Specific UI Logic That Dominates
When a screen's behavior is heavily tied to platform APIs (e.g., camera capture, complex animations), sharing state logic may not save effort. The platform-specific code will dominate, and the shared state layer becomes a thin wrapper that adds abstraction without benefit. In such cases, it is better to keep state logic in platform code and share only the data models.
Open Questions and Practical FAQ
Even with established patterns, several questions remain unresolved in the community. This section addresses the most common ones with practical guidance.
How do we handle navigation events in a unidirectional flow?
Navigation is a side effect, not state. Use a SharedFlow with replay=0 to emit navigation events, and collect it in platform code. Avoid putting navigation state in StateFlow, as it would persist across recompositions and cause duplicate navigations. Some teams use a sealed class for navigation events and a dedicated channel.
Should we use a state management library like Redux KMP?
Libraries like Redux KMP or Orbit MVI provide structure but also impose a specific mental model. They are beneficial for large teams that need strict conventions, but they add boilerplate and learning curve. For smaller teams, a custom reducer pattern with StateFlow often suffices. Evaluate based on team size and feature complexity.
How do we test state flows in commonMain?
Use Turbine library for testing Flow emissions. It integrates with kotlinx-coroutines-test and provides a clean API to assert on emitted values. For reducers, unit tests are straightforward: call the reducer with a state and event, and assert the new state. Always test with a test dispatcher to avoid flakiness.
As multiplatform development matures, the patterns we adopt today will evolve. The best investment is not in a specific library but in a culture of intentional design—choosing patterns that match the problem's complexity and revisiting them as the project grows. Start with the simplest thing that could possibly work, and escalate only when the pain of simplicity exceeds the cost of abstraction.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!