DEV Community

Cover image for From Data to UI with RequestState in Kotlin Multiplatform
Shiva Thapa
Shiva Thapa

Posted on • Originally published at Medium

From Data to UI with RequestState in Kotlin Multiplatform

Part 3 of 4 — This part builds on the ResponseResult contract from Part 2 and the HttpClient setup from Part 1. If you're jumping in here, those two parts cover the layers this one sits on top of.


In Part 2, we built a networking layer where every call returns a ResponseResult<T>. The repository knows nothing about loading state, error dialogs, or UI updates — it just makes the call and returns a typed result. Clean, testable, consistent.

But the ViewModel still has to react to those results. It needs to set loading to true before the call, update state when it succeeds, show an error when it fails, and set loading to false when it's done. Across a real app with dozens of screens, this ceremony adds up fast. And if every developer handles it slightly differently, you end up with the same inconsistency problem you had in the repository layer — just one floor higher.

This part solves that. We'll build RequestState — the UI-side equivalent of ResponseResult — and wire it to executeNetworkCall and executeNetworkCallWithState, which handle the entire loading/success/error lifecycle in a single call. Finally, we'll look at the Compose extensions that let your UI react to state changes in a single, animated, readable block.


The Gap Between Repository and UI

When a ViewModel calls a repository function, it gets a ResponseResult<T>. But the UI doesn't just need to know whether the data arrived — it needs to know where in the journey it is. Before the call starts, nothing has happened yet. During the call, something is loading. When it completes, data is available. When it fails, an error needs to be shown. When the server responds with nothing, an empty state needs to be rendered.

ResponseResult models the outcome of a call. What we need for the UI is something that models the state over time. That's RequestState.


RequestState — UI State That Tells the Whole Story

sealed class RequestState<out T> {
    data object Idle    : RequestState<Nothing>()
    data object Loading : RequestState<Nothing>()
    data class  Success<out T>(val data: T) : RequestState<T>()
    data class  Error(
        val message: String,
        val code: Int? = null,
        val serverMessage: String? = null,
        val errorType: ResponseResult.ErrorType? = null
    ) : RequestState<Nothing>()
    data object Empty   : RequestState<Nothing>()
}
Enter fullscreen mode Exit fullscreen mode

Five states. Each one corresponds to a real moment in the lifecycle of a network call.

Idle — the initial state before anything has been requested. A screen that hasn't loaded yet isn't in a loading state; it hasn't started. This distinction matters when you want to avoid flashing a loading indicator on first render, or when you need to show a "tap to load" placeholder.

Loading — a request is in flight.

Success<T> — the request succeeded. Notice that unlike ResponseResult.Success, the data here is non-nullable. By the time a success state reaches the UI, it should carry real data. If the server returned nothing, that's Empty, not Success(null).

Error — carries the same fields as ResponseResult.Error: a user-facing message, the status code, the server message, and the ErrorType enum for branching on failure kind.

Empty — the request succeeded but there's nothing to show. Your UI can render an empty placeholder without having to check Success(emptyList()).

Helper Functions

fun isIdle()    : Boolean = this is Idle
fun isLoading() : Boolean = this is Loading
fun isError()   : Boolean = this is Error
fun isSuccess() : Boolean = this is Success
fun isEmpty()   : Boolean = this is Empty

fun getSuccessData()       = (this as Success).data
fun getSuccessDataOrNull() = if (this is Success) this.data else null
fun getErrorMessage()      = (this as Error).message
fun getErrorMessageOrNull() = if (this is Error) this.message else null
Enter fullscreen mode Exit fullscreen mode

isLoading() and isSuccess() are particularly useful in ViewModels when you need to guard against duplicate requests — for example, not starting a new fetch if one is already running.

Transformation

RequestState supports map and mapNotNull, which work just like their ResponseResult counterparts:

fun <T, R> RequestState<T>.map(transform: (T) -> R): RequestState<R> {
    return when (this) {
        is RequestState.Success -> RequestState.Success(transform(this.data))
        is RequestState.Error   -> this
        is RequestState.Loading -> RequestState.Loading
        is RequestState.Idle    -> RequestState.Idle
        is RequestState.Empty   -> RequestState.Empty
    }
}

fun <T, R> RequestState<T>.mapNotNull(transform: (T) -> R?): RequestState<R> {
    return when (this) {
        is RequestState.Success -> {
            val result = transform(this.data)
            if (result != null) RequestState.Success(result) else RequestState.Empty
        }
        is RequestState.Error   -> this
        is RequestState.Loading -> RequestState.Loading
        is RequestState.Idle    -> RequestState.Idle
        is RequestState.Empty   -> RequestState.Empty
    }
}
Enter fullscreen mode Exit fullscreen mode

Non-success states pass through untouched. No pattern matching on every branch manually.

Inline Callbacks

inline fun <reified T> RequestState<T>.onSuccess(action: (T) -> Unit): RequestState<T> {
    if (this is RequestState.Success) action(data)
    return this
}

inline fun <reified T> RequestState<T>.onError(action: (String) -> Unit): RequestState<T> {
    if (this is RequestState.Error) action(message)
    return this
}

inline fun <reified T> RequestState<T>.onLoading(action: () -> Unit): RequestState<T> {
    if (this is RequestState.Loading) action()
    return this
}

inline fun <reified T> RequestState<T>.onEmpty(action: () -> Unit): RequestState<T> {
    if (this is RequestState.Empty) action()
    return this
}
Enter fullscreen mode Exit fullscreen mode

These are chainable. You can write:

requestState
    .onSuccess { data -> renderContent(data) }
    .onError   { message -> showError(message) }
    .onEmpty   { showEmptyState() }
Enter fullscreen mode Exit fullscreen mode

handleResult — For When You Need All Five Branches

fun <T> RequestState<T>.handleResult(
    onIdle:    (() -> Unit)? = null,
    onLoading: (() -> Unit)? = null,
    onError:   ((message: String, code: Int?, serverMessage: String?, errorType: ResponseResult.ErrorType?) -> Unit)? = null,
    onSuccess: (T) -> Unit,
    onEmpty:   (() -> Unit)? = null
) {
    when (this) {
        is RequestState.Idle    -> onIdle?.invoke()
        is RequestState.Loading -> onLoading?.invoke()
        is RequestState.Error   -> onError?.invoke(message, code, serverMessage, errorType)
        is RequestState.Success -> onSuccess(getSuccessData())
        is RequestState.Empty   -> onEmpty?.invoke()
    }
}
Enter fullscreen mode Exit fullscreen mode

All callbacks except onSuccess are optional. This is used when multiple RequestState values drive different sections of the same scrollable screen — each section with its own loading skeleton, error state, and content:

uiState.weatherForecast.handleResult(
    onSuccess = { forecast ->
        item(key = "WeatherDetail") {
            WeatherDetailCard(forecast = forecast)
        }
    },
    onError = { _, _, serverMessage, _ ->
        item(key = "WeatherError") {
            NetworkErrorCard(
                onRetry = { viewModel.retryWeatherFetch() },
                errorMessage = serverMessage ?: stringResource(Res.string.could_not_load_weather)
            )
        }
    },
    onLoading = {
        item(key = "WeatherLoading") {
            WeatherLoadingSkeleton()
        }
    }
)
Enter fullscreen mode Exit fullscreen mode

No when expressions scattered across the screen. No if (isLoading) show else hide logic. The pattern is the same every time.


executeNetworkCall — Wiring the Repository to the ViewModel

suspend inline fun <T : Any> executeNetworkCall(
    crossinline call: suspend () -> ResponseResult<T>,
    crossinline onLoadingChanged: (Boolean) -> Unit = {},
    crossinline onSuccess: (T) -> Unit,
    crossinline onError: (message: String, code: Int?, serverMessage: String?, errorType: ResponseResult.ErrorType?) -> Unit,
    crossinline onEmpty: () -> Unit = {}
) {
    onLoadingChanged(true)

    when (val result = call()) {
        is ResponseResult.Success -> {
            onLoadingChanged(false)
            result.data?.let(onSuccess) ?: onEmpty()
        }
        is ResponseResult.Error -> {
            onLoadingChanged(false)
            onError(result.message, result.code, result.serverMessage, result.errorType)
        }
        is ResponseResult.Empty -> {
            onLoadingChanged(false)
            onEmpty()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This function takes a suspended call that returns a ResponseResult, fires the loading callback when the call starts, and routes the result to the appropriate callback when it completes. The loading flag is always reset — in every branch, before the relevant callback fires — regardless of how the call ends.

A simple ViewModel function using it:

fun fetchBanners(): Job {
    return viewModelScope.launch {
        executeNetworkCall(
            call = { homeRepository.fetchBanners() },
            onSuccess = { banners -> _bannerList.value = banners },
            onError = { message, _, serverMessage, _ ->
                // handle silently or show a snackbar
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Three lines of intent: what to call, what to do on success, what to do on error. No try-catch. No manual loading flag management. No finally block.

For calls with visible loading state or file upload progress:

fun submitQuery() {
    viewModelScope.launch {
        executeNetworkCall(
            call = {
                repository.submitQuery(
                    payload = uiState.value.userQuery,
                    onProgress = { sent, total, _ ->
                        updateUploadStatus(UploadStatus(sent, total))
                    }
                )
            },
            onLoadingChanged = { isLoading ->
                _uiState.update { it.copy(isSending = isLoading) }
            },
            onSuccess = { response ->
                resetUserQuery()
                _uiEvents.tryEmit(UiEvent.NavigateToChat(response.id))
            },
            onError = { message, _, serverMessage, _ ->
                dialogStateHolder.showError(serverMessage ?: message)
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

executeNetworkCallWithState — When the State Itself Lives in the ViewModel

executeNetworkCall is for fire-and-update patterns. But sometimes you want the RequestState itself stored in UiState, so the UI can react to every transition — idle, loading, success, error, empty — directly.

suspend inline fun <T : Any> executeNetworkCallWithState(
    crossinline call: suspend () -> ResponseResult<T>,
    crossinline onAllEvents: (RequestState<T>) -> Unit = {},
    crossinline onLoadingChanged: (Boolean) -> Unit = {},
    crossinline onSuccess: (T) -> Unit = {},
    crossinline onError: (message: String, code: Int?, serverMessage: String?, errorType: ResponseResult.ErrorType?) -> RequestState<T> = {
            message, code, serverMessage, errorType ->
        RequestState.Error(message = message, code = code, serverMessage = serverMessage, errorType = errorType)
    },
    crossinline onEmpty: () -> RequestState<T> = { RequestState.Empty }
): RequestState<T> {
    return try {
        onLoadingChanged(true)
        onAllEvents(RequestState.Loading)

        when (val result = call()) {
            is ResponseResult.Success -> {
                result.data?.let { data ->
                    val successState = RequestState.Success(data)
                    onAllEvents(successState)
                    onSuccess(data)
                    successState
                } ?: run {
                    onAllEvents(RequestState.Empty)
                    RequestState.Empty
                }
            }
            is ResponseResult.Error -> {
                val errorState = RequestState.Error(
                    message = result.message,
                    code = result.code,
                    serverMessage = result.serverMessage,
                    errorType = result.errorType
                )
                onAllEvents(errorState)
                onError(result.message, result.code, result.serverMessage, result.errorType)
                errorState
            }
            is ResponseResult.Empty -> {
                onAllEvents(RequestState.Empty)
                onEmpty()
                RequestState.Empty
            }
        }
    } catch (e: Exception) {
        val errorState = RequestState.Error(
            message = e.message ?: "An unknown error occurred",
            errorType = if (e.message.isNullOrBlank()) ResponseResult.ErrorType.UNKNOWN else null
        )
        onAllEvents(errorState)
        errorState
    } finally {
        onLoadingChanged(false)
    }
}
Enter fullscreen mode Exit fullscreen mode

The key difference is onAllEvents. This single callback receives every state transition — Loading, Success, Error, Empty — making it trivial to pipe all transitions into one ViewModel field:

fun fetchWeatherForecast(lat: Double, lon: Double) {
    viewModelScope.launch {
        executeNetworkCallWithState(
            call = { weatherRepository.getDailyForecast(lat, lon) },
            onAllEvents = { state ->
                _uiState.update { it.copy(weatherForecast = state) }
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

One line in onAllEvents. Loading state, success data, and any error all flow into the same UiState field. No separate isLoading boolean. No separate error string. The state machine is the state.

When you need extra logic on success — updating a cache, auto-selecting an item — onSuccess is there as an opt-in side channel alongside onAllEvents:

fun fetchNotificationCategories() {
    viewModelScope.launch {
        executeNetworkCallWithState(
            call = { notificationRepository.fetchCategories() },
            onSuccess = { categories ->
                // Auto-select the first category if none is selected yet
                if (uiState.value.selectedCategory == null) {
                    updateSelectedCategory(categories.firstOrNull(), index = null)
                }
            },
            onAllEvents = { state ->
                _uiState.update { it.copy(categories = state) }
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Both callbacks fire independently — they're not mutually exclusive.


The UiState Pattern

RequestState pairs naturally with a data class UiState exposed as a StateFlow:

data class UiState(
    val weatherForecast:     RequestState<List<DailyForecast>?> = RequestState.Idle,
    val marketPrices:        RequestState<List<MarketPrice>>    = RequestState.Idle,
    val notificationCount:   NotificationCount                  = NotificationCount(),
    val isRefreshing:        Boolean                            = false,
    // ...
)

private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
Enter fullscreen mode Exit fullscreen mode

Every RequestState field starts as Idle. As the ViewModel fires its initial requests in init, those fields transition through Loading to Success, Error, or Empty. The UI observes the StateFlow and renders whatever state it currently holds.

The ViewModel no longer needs three separate flags (isLoading, hasError, data) per network request. The entire lifecycle lives in one field, in one value, in one state machine.


Bringing It to the UI — displayResult and displayResultWithDefaults

Two Composable extension functions on RequestState<T> handle rendering.

displayResult — Full Control

@Composable
fun <T> RequestState<T>.displayResult(
    onSuccess: @Composable (T) -> Unit,
    modifier: Modifier = Modifier,
    parentModifier: Modifier = Modifier,
    onLoading: (@Composable () -> Unit)? = null,
    onIdle:    (@Composable () -> Unit)? = onLoading,
    onError:   (@Composable (String, Int?, String?, ResponseResult.ErrorType?) -> Unit)? = null,
    onEmpty:   (@Composable () -> Unit)? = null,
    transitionSpec: ContentTransform =
        fadeIn(tween(800)) togetherWith fadeOut(tween(800)),
    backgroundColor: Color? = null,
    contentAlignment: Alignment = Alignment.Center
) {
    AnimatedContent(
        modifier = parentModifier.background(color = backgroundColor ?: Color.Unspecified),
        targetState = this,
        transitionSpec = { transitionSpec },
        label = "ContentAnimation"
    ) { state ->
        Box(
            modifier = modifier.fillMaxWidth()
                .background(color = backgroundColor ?: Color.Unspecified),
            contentAlignment = contentAlignment
        ) {
            when (state) {
                is RequestState.Idle    -> onIdle?.invoke()
                is RequestState.Loading -> onLoading?.invoke()
                is RequestState.Error   -> onError?.invoke(
                    state.message, state.code, state.serverMessage, state.errorType
                )
                is RequestState.Success -> onSuccess(state.data)
                is RequestState.Empty   -> onEmpty?.invoke()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

displayResult is the flexible version. All callbacks except onSuccess are optional. If you don't pass onLoading, nothing renders during loading — useful for non-critical sections where a spinner would be distracting. The whole thing is wrapped in AnimatedContent, so every state transition cross-fades automatically.

Use displayResult when a screen section has custom loading or error UI that doesn't match the app defaults:

profileState.displayResult(
    onSuccess = { profile -> ProfileContent(profile) },
    onLoading = { ProfileLoadingSkeleton() },
    onError   = { message, _, serverMessage, _ ->
        NetworkErrorCard(
            onRetry = onRetry,
            errorMessage = serverMessage ?: message
        )
    }
)
Enter fullscreen mode Exit fullscreen mode

displayResultWithDefaults — Convention Over Configuration

@Composable
fun <T> RequestState<T>.displayResultWithDefaults(
    onSuccess: @Composable (T) -> Unit,
    modifier: Modifier = Modifier,
    onLoading: (@Composable () -> Unit)? = { DefaultLoadingIndicator() },
    onIdle:    (@Composable () -> Unit)? = onLoading,
    onError:   (@Composable (String, Int?, String?, ResponseResult.ErrorType?) -> Unit)? = {
        errorMessage, _, serverError, _ ->
            DefaultErrorText(errorMessage = serverError ?: errorMessage)
    },
    onEmpty:   (@Composable () -> Unit)? = { DefaultEmptyContent() },
    transitionSpec: ContentTransform =
        fadeIn(tween(800)) togetherWith fadeOut(tween(800)),
    backgroundColor: Color? = null
) {
    AnimatedContent(
        modifier = modifier.background(color = backgroundColor ?: Color.Unspecified),
        targetState = this,
        transitionSpec = { transitionSpec },
        label = "ContentAnimation"
    ) { state ->
        when (state) {
            is RequestState.Idle    -> onIdle?.invoke()
            is RequestState.Loading -> onLoading?.invoke()
            is RequestState.Error   -> onError?.invoke(
                state.message, state.code, state.serverMessage, state.errorType
            )
            is RequestState.Success -> onSuccess(state.data)
            is RequestState.Empty   -> onEmpty?.invoke()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

displayResultWithDefaults pre-wires app-wide defaults for each state:

  • Loading / IdleDefaultLoadingIndicator() — your animated loading indicator
  • ErrorDefaultErrorText() — server message if available, falls back to user message
  • EmptyDefaultEmptyContent() — a "no data found" placeholder

For the vast majority of screens, all you write is the success content — and override only what needs to be different:

uiState.categories.displayResultWithDefaults(
    onSuccess = { categories ->
        CategoryChips(
            items = categories,
            selectedItem = uiState.selectedCategory,
            onChipClick = { index, category ->
                viewModel.updateSelectedCategory(category, index)
            }
        )
    },
    onError = { message, _, serverMessage, _ ->
        NetworkErrorCard(
            onRetry = { viewModel.fetchCategories() },
            errorMessage = serverMessage ?: message
        )
    },
    onLoading = {
        CategoryChipsLoadingSkeleton(modifier = Modifier.fillMaxWidth())
    }
)
Enter fullscreen mode Exit fullscreen mode

The convention handles the boilerplate. You handle the product logic.


The Full Loop

Here's the complete journey of a single data request, from first render to content on screen:

1. ViewModel initialises — field starts as Idle

val uiState = MutableStateFlow(UiState(categories = RequestState.Idle))
Enter fullscreen mode Exit fullscreen mode

2. ViewModel fires the request — state transitions to Loading

executeNetworkCallWithState(
    call = { repository.fetchCategories() },
    onAllEvents = { state -> _uiState.update { it.copy(categories = state) } }
)
// onAllEvents fires with RequestState.Loading immediately
Enter fullscreen mode Exit fullscreen mode

3. UI observes the StateFlow — renders loading state

val uiState by viewModel.uiState.collectAsState()

uiState.categories.displayResultWithDefaults(
    onSuccess = { categories -> CategoryChips(categories) }
)
// AnimatedContent fades in DefaultLoadingIndicator()
Enter fullscreen mode Exit fullscreen mode

4. Request completes — state transitions to Success or Error

// onAllEvents fires with RequestState.Success(categories)
// _uiState updates, StateFlow emits
Enter fullscreen mode Exit fullscreen mode

5. UI re-renders with the new state

// AnimatedContent cross-fades from loading indicator to category chips
Enter fullscreen mode Exit fullscreen mode

One StateFlow. No separate loading boolean. No nullable data field to null-check. No error string to conditionally show. The state machine carries all of it.


When to Use Which

executeNetworkCall — use when the result updates a simple value or triggers a side effect. The data flows into a regular field, not a RequestState. Good for background loads where you don't need a loading skeleton: banner lists, notification counts, task reminders.

executeNetworkCallWithState — use when the RequestState itself should be stored in UiState. The UI needs to show loading skeletons and error retry buttons for this content.

displayResult — use when you need full control over every state's UI. The loading or error composable is unique to this context.

displayResultWithDefaults — use everywhere else. Consistent loading, error, and empty states across the whole app, with minimal code.


What We've Built

Across the first three parts, the full request lifecycle is covered end to end:

  • The HttpClient (Part 1) handles auth, retries, and connection concerns
  • ResponseResult and safeRequest (Part 2) handle every failure mode at the boundary and give repositories a typed return contract
  • RequestState, executeNetworkCall, executeNetworkCallWithState, and the displayResult extensions (this part) carry the result through the ViewModel into the UI with animated state transitions, consistent defaults, and zero boilerplate

A developer joining the team can look at any ViewModel or any screen Composable and immediately understand what's happening. State is always a RequestState. Network calls always go through executeNetworkCall or executeNetworkCallWithState. UI always renders through displayResult or displayResultWithDefaults.


What's Next

In Part 4, we'll extend this architecture into the three scenarios that every production app eventually hits: paginated lists with GenericPagingSource and PagingStateHandler, live data streams with WebSocketClientConnection and observeWebSocketMessages, and coordinated multi-source refresh with RefreshManager. None of them require new primitives — they all feed back into the same RequestState-based pattern you now know.


Found this useful? Drop a ❤️ or leave a comment — it helps more developers find the series.

Top comments (0)