Skip to content

Recipe: Show loading, loaded, and error states

Problem: a screen loads data asynchronously and needs to render distinct loading, success, and error UI.

Model the three outcomes as a sealed State, and give each variant only the events that make sense for it — Loading has no events, Error only retries. The presenter maps the repository’s result into the right variant.

sealed interface ProfileState : CircuitUiState {
  data object Loading : ProfileState

  data class Loaded(
    val name: String,
    val eventSink: (LoadedEvent) -> Unit,
  ) : ProfileState

  data class Error(
    val message: String,
    val eventSink: (ErrorEvent) -> Unit,
  ) : ProfileState
}

sealed interface LoadedEvent : CircuitUiEvent {
  data object Refresh : LoadedEvent
}

sealed interface ErrorEvent : CircuitUiEvent {
  data object Retry : ErrorEvent
}

The presenter collects the repository (which exposes its own loading/success/error result type) and translates each case. See observing a Flow for why the Flow is built inside produceRetainedState.

@Composable
override fun present(): ProfileState {
  val result by produceRetainedState<ProfileResult>(ProfileResult.Loading, screen.userId) {
    profileRepository.profile(screen.userId).collect { fetched -> value = fetched }
  }

  return when (val current = result) {
    ProfileResult.Loading -> ProfileState.Loading
    is ProfileResult.Success ->
      ProfileState.Loaded(name = current.name) { event ->
        when (event) {
          LoadedEvent.Refresh -> profileRepository.refresh(screen.userId)
        }
      }
    is ProfileResult.Failure ->
      ProfileState.Error(message = current.message) { event ->
        when (event) {
          ErrorEvent.Retry -> profileRepository.refresh(screen.userId)
        }
      }
  }
}

The UI dispatches on the sealed state — each branch only sees the events it’s allowed to send:

@Composable
fun Profile(state: ProfileState, modifier: Modifier = Modifier) {
  when (state) {
    ProfileState.Loading -> CircularProgressIndicator(modifier)
    is ProfileState.Loaded ->
      ProfileBody(state.name, onRefresh = { state.eventSink(LoadedEvent.Refresh) }, modifier)
    is ProfileState.Error ->
      ErrorView(state.message, onRetry = { state.eventSink(ErrorEvent.Retry) }, modifier)
  }
}

See also: States and Events · Retry a failed load · Pull to refresh