Back to Blog

State Management in Kotlin Multiplatform: Share Logic, Not UI State

Syncing state across mobile platforms has historically been a major source of bugs. Different lifecycle models, caching behaviors, and reactive paradigms often lead to divergent user experiences on Android and iOS.

With Kotlin Multiplatform (KMP), the goal isn't just to share code—it's to share behavior. By centering your architecture around unidirectional data flow (UDF) and Kotlin Coroutines, you can build a shared state machine that powers both your Jetpack Compose and SwiftUI interfaces seamlessly.


The Core Concept: Share the State Machine, Not the Canvas

The most important rule of KMP architecture is: Decouple business logic from the UI. The shared module should act as a pure state engine. It accepts user intents (events), processes them, and publishes immutable UI state.

       +--------------------------------------------+
       |                  Shared Core               |
       |                                            |
User Event --> [Repository] --> [ViewModel/State] --+--> UI State (StateFlow)
       |                                            |
       +--------------------------------------------+
                               |
            +------------------+------------------+
            |                                     |
            v                                     v
    [Jetpack Compose]                         [SwiftUI]
      (Android UI)                           (iOS UI)

1. Defining UI State and User Events

We start by modeling our screen state and user actions inside commonMain. By keeping state classes immutable, we prevent side effects and multithreading state corruption.

// commonMain/kotlin/com/example/portfolio/feed/FeedState.kt

data class FeedUiState(
    val isLoading: Boolean = false,
    val items: List<FeedItem> = emptyList(),
    val errorMessage: String? = null
)

sealed interface FeedUiEvent {
    data object Refresh : FeedUiEvent
    data class LikeItem(val itemId: String) : FeedUiEvent
}

2. Implementing the Shared ViewModel / StateHolder

To manage this state, we create a shared state holder class. Because Android's androidx.lifecycle.ViewModel is native to the JVM, KMP projects historically used custom state holders. However, with modern KMP, we can use a shared base class or define a multiplatform class that manages a CoroutineScope.

// commonMain/kotlin/com/example/portfolio/feed/FeedViewModel.kt

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

open class FeedViewModel(
    private val repository: FeedRepository
) {
    // A coroutine scope bound to the lifecycle of this ViewModel
    private val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

    private val _uiState = MutableStateFlow(FeedUiState())
    val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()

    init {
        loadFeed()
    }

    fun onEvent(event: FeedUiEvent) {
        when (event) {
            is FeedUiEvent.Refresh -> loadFeed()
            is FeedUiEvent.LikeItem -> likeItem(event.itemId)
        }
    }

    private fun loadFeed() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            try {
                val feedItems = repository.getFeed()
                _uiState.update { it.copy(isLoading = false, items = feedItems, errorMessage = null) }
            } catch (e: Exception) {
                _uiState.update { it.copy(isLoading = false, errorMessage = e.message ?: "Unknown Error") }
            }
        }
    }

    private fun likeItem(itemId: String) {
        viewModelScope.launch {
            repository.toggleLike(itemId)
            // Perform local optimistic update
            _uiState.update { state ->
                state.copy(items = state.items.map { 
                    if (it.id == itemId) it.copy(isLiked = !it.isLiked) else it 
                })
            }
        }
    }
}

3. Consuming State in Android (Jetpack Compose)

On Android, consuming the shared state flow is straightforward. We can use the lifecycle-aware state collections provided by Compose:

// androidMain/kotlin/com/example/portfolio/feed/FeedScreen.kt

@Composable
fun FeedScreen(viewModel: FeedViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    Box(modifier = Modifier.fillMaxSize()) {
        if (uiState.isLoading) {
            CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
        }

        FeedList(
            items = uiState.items,
            onRefresh = { viewModel.onEvent(FeedUiEvent.Refresh) },
            onLike = { id -> viewModel.onEvent(FeedUiEvent.LikeItem(id)) }
        )

        uiState.errorMessage?.let { error ->
            Text(text = error, color = Color.Red, modifier = Modifier.align(Alignment.BottomCenter))
        }
    }
}

4. Consuming State in iOS (SwiftUI)

In Swift, consuming a Kotlin StateFlow directly can be challenging because Swift doesn't natively support Kotlin Coroutine flows. To bridge this gap, we can create a lightweight helper in our iOS-specific wrapper or use a Swift adapter class.

Here is a common pattern using a Swift wrapper that conforms to ObservableObject:

// iosApp/iosApp/FeedStateObserver.swift

import Foundation
import shared // Your shared Kotlin framework

class FeedStateObserver: ObservableObject {
    private let viewModel: FeedViewModel
    @Published var state: FeedUiState = FeedUiState(isLoading: false, items: [], errorMessage: nil)
    private var stateWatcher: Closeable? = nil

    init(viewModel: FeedViewModel) {
        self.viewModel = viewModel
        
        // Listen to the Kotlin StateFlow updates
        self.stateWatcher = viewModel.uiState.watch { [weak self] newState in
            self?.state = newState as! FeedUiState
        }
    }

    func send(event: FeedUiEvent) {
        viewModel.onEvent(event: event)
    }

    deinit {
        stateWatcher?.close()
    }
}

Note: In Swift, watch is a simple extension function written in Kotlin to easily subscribe to flows from Swift without dealing with generic type erasure.

// iosMain/kotlin/com/example/portfolio/utils/FlowUtils.kt

fun <T> StateFlow<T>.watch(block: (T) -> Unit): Closeable {
    val job = CoroutineScope(SupervisorJob() + Dispatchers.Main).launch {
        collect { block(it) }
    }
    return object : Closeable {
        override fun close() {
            job.cancel()
        }
    }
}

interface Closeable {
    fun close()
}

Now, your SwiftUI view can consume this wrapper cleanly:

// iosApp/iosApp/FeedView.swift

struct FeedView: View {
    @StateObject private var observer: FeedStateObserver

    init(viewModel: FeedViewModel) {
        _observer = StateObject(wrappedValue: FeedStateObserver(viewModel: viewModel))
    }

    var body: some View {
        ZStack {
            if observer.state.isLoading {
                ProgressView()
            }

            List(observer.state.items, id: \.id) { item in
                FeedItemRow(item: item) {
                    observer.send(event: FeedUiEvent.LikeItem(itemId: item.id))
                }
            }
            .refreshable {
                observer.send(event: FeedUiEvent.Refresh())
            }
        }
    }
}

Concurrency & Garbage Collection Considerations

When working with shared state flow, keep these multiplatform quirks in mind:

  1. Main Thread Alignment: Both SwiftUI and Jetpack Compose expect UI state changes on the Main Thread. Ensure your shared view models emit states using Dispatchers.Main.immediate or a main-thread-confined runner.
  2. Memory Leak Prevention: On iOS, the ObservableObject watcher maintains a subscription. You must cancel this subscription when Swift views are dismissed (done via the deinit block in FeedStateObserver which calls Closeable.close()).

Conclusion

Sharing your application's state machine using unidirectional data flow dramatically improves product reliability. Both platforms share 100% of the caching, networking, error handling, and transition logic. The native view files (Compose and SwiftUI) are reduced to simple, declarative canvas drawings that respond directly to state changes.

For further steps, check out my guide on Setting Up Ktor for Networking in Kotlin Multiplatform to feed data into your ViewModels, or learn how to decouple dependencies natively in my tutorial on Mastering Dependency Injection with Koin.


Need a Kotlin Multiplatform Expert?

Looking to align your Android and iOS state management architectures or optimize your shared codebase design? I provide remote contract development and consultancy for teams in Europe, the UK, and Canada with flexible timezone support (CEST, GMT, EST).

Let's Connect & Discuss Your Project