Adetunji Dahunsi

UDF with Functional Reactive Programming: A case study

A practical example of unidirectional data flow with Kotlin coroutines and Flows on Android

TJ Dahunsi

Jul 10 2023 ยท 14 min read

The following is not official guidance. It is a zealous and very strict UDF implementation that aligns with my preference for functional reactive programming and immutable states. Please refer to the official Android architecture state production guide in your production applications.

Pure functions in state production

I try to build state production pipelines that consist of only pure functions. That is to produce state, the functions never require variables outside their explicitly passed arguments. Consider the following snippet:

1data class ItemUiState( 2 val itemId: String, 3 val item: Item? = null 4) 5 6class ItemViewModel( 7 savedStateHandle: SavedStateHandle, 8 private val itemRepository: ItemRepository 9) : ViewModel() { 10 11 private val _uiState = MutableStateFlow( 12 ItemUiState(itemId = savedStateHandle.get<String>(ID_ARG)!!) 13 ) 14 val uiState: StateFlow<ItemUiState> = _uiState.asStateFlow() 15 16 fun loadItem() { 17 viewModelScope.launch { 18 try { 19 val loadedItem = itemRepository.loadItem( 20 itemId = _uiState.value.itemId 21 ) 22 _uiState.update { 23 it.copy(item = loadedItem) 24 } 25 } 26 catch(exception: Exception) { 27 ... 28 } 29 } 30 } 31}

This is standard fare and good practice, however the loadItem() method isn't a pure function; it requires access to the ViewModel field variables itemRepository and _uiState. Fixing this is simple, if inconvenient:

1fun loadItem( 2 scope: CoroutineScope, 3 uiState: MutableStateFlow<ItemUiState>, 4 itemRepository: ItemRepository, 5) { 6... 7}

The change above does make it a pure function, but it's verbose to the point of being impractical. Nonetheless, it's something I obsess over and try to make all my state production pipelines resemble.

I go through this seemingly pedantic process because shared mutable states in class fields have no restrictions on concurrent access. loadItem() may be called multiple times, causing multiple coroutines to concurrently mutate fields with a class level scope. This can be mitigated by holding references to launched Jobs or using a Mutex, but scaling these across multiple fields, different screens and multiple contributors poses a new set of organizational and managerial challenges. Failing to address the issue however causes a whole class of bugs I would much rather not deal with in highly concurrent applications.

State production rules

Ultimately I strive for my state production pipelines to:

  1. Consist of pure functions: State production functions may only read from the arguments explicitly passed to it.
  2. Have a static context: State production functions must have no access to field variables of any class. This enforces 1.
  3. Be side effect free: State production functions must self contain task cancellation, retries, and asynchronous fetches in their bodies. Cancellation cannot be triggered from outside the function scope, no Job or Disposable references that may be called upon from outside the function.
  4. Require an observer: State production functions must require the presence of an observer to run; no Schrodinger's state unless explicitly made hot. This implicitly means they must be restartable.
  5. Be homogenous: State production semantics are homogenous across screens. If someone contributed to state production for screen A, the same mental model can be applied to all other screens.

The rest of this post outlines my method for achieving this.

Inline reducers as declarative state production functions

The CoroutineScope and MutableStateFlow arguments can be eliminated by modeling state production with flows. To familiarize yourself with this concept I recommend reading:

As a brief recap, a change in a State object can be defined inline as a Mutation<State>.

1typealias Mutation<State> = State.() -> State

State production is effectively applying multiple Mutation instances to an initial State over time. The signature of the typealias is identical to the function argument of MutableStateFlow.update(). This is also the same idea popularized by Redux; however instead of writing a single reducer function that all actions and/or effects share, multiple reducing functions are written inline as a Mutation. Perhaps more conveniently, there is no need for side effect abstractions. All asynchronous actions can be performed inline in the Flow by suspending, mapping, flatmapping or what have you. This eliminates the boiler plate of having to define a wrapper Action/Effect for every possible event that can change your state. Instead, you simply declare and dispatch the change.

This lets us rewrite the example above as a pure function without having to pass the CoroutineScope and MutableStateFlow as arguments every time, and also to define loadItem() as an extension on ItemRepository communicating clearly that the ItemRepository is the source of state change:

1// Original function 2 fun loadItem() { 3 viewModelScope.launch { 4 try { 5 val loadedItem = itemRepository.loadItem( 6 itemId = _uiState.value.itemId 7 ) 8 _uiState.update { 9 it.copy(item = loadedItem) 10 } 11 } 12 catch(exception: Exception) { 13 ... 14 } 15 } 16 } 17 18// Static pure function 19fun ItemRepository.loadItem(itemId: String) = flow<Mutation<ItemUiState>> { 20 try { 21 val loadedItem = loadItem(itemId) 22 // Equivalent to mutableStateFlow.update 23 emit { 24 copy(item = loadedItem) 25 } 26 } 27 catch(exception: Exception) { 28 ... 29 } 30}

For user actions, the extension is on the Flow of that action. If the user wanted to retry loading for example, the method definition would be:

1fun Flow<Action.Retry>.reloadMutations( 2 repository: ItemRepository 3) = mapLatest { retryAction -> 4 try { 5 repository.loadItem(retryAction.itemId) 6 } catch (exception: Exception) { 7 ... 8 // Return null to signal failure 9 null 10 } 11} 12 // Equivalent to MutableStateFlow.update { } 13 .mapToMutation { loadedItem -> 14 // One may opt to add an 15 // error message here if the loadedItem is null 16 copy(item = loadedItem) 17 }
1inline fun <T, State> Flow<T>.mapToMutation( 2 crossinline mapper: State.(T) -> State 3): Flow<Mutation<State>> = 4 // emit the update/mutation/reducing function 5 map { item -> mutation { mapper(item) } }

Case study

The following is a case study on the approach described above in my fork of the open source Musify Spotify Jetpack Compose clone on the playground branch. State production pipelines for all screens follow the rules above and are assembled with the Mutator library.

The screenshots that follow are for the podcast detail screen in the app:

imageimage

The UI state that describes the screen and the actions a user can take on the screen are defined as:

1sealed class PodcastShowDetailAction { 2 object Retry : PodcastShowDetailAction() 3 data class LoadAround(val podcastQuery: PodcastQuery?) : PodcastShowDetailAction() 4} 5 6data class PodcastShowDetailUiState( 7 val currentQuery: PodcastQuery, 8 val podcastShow: PodcastShow? = null, 9 val currentlyPlayingEpisode: PodcastEpisode? = null, 10 val isCurrentlyPlayingEpisodePaused: Boolean? = null, 11 val loadingState: LoadingState = LoadingState.LOADING, 12 val episodesForShow: TiledList<PodcastQuery, PodcastEpisode> = emptyTiledList() 13) { 14 enum class LoadingState { IDLE, LOADING, PLAYBACK_LOADING, ERROR } 15}

The entry point to state production for the screen is a ViewModel. Hilt is used to inject all the inputs of the state production pipeline. These inputs are then used to assemble the PodcastShowDetailStateProducer. This separation between the ViewModel and the app's state production semantics makes state production:

  • Independent of and testable outside of the Android platform.
  • Portable accross platforms.

The ViewModel becomes the Android entrypoint for state production and the business logic state holder for the platform's UI, this is the benefit espoused in the documentation.

1@HiltViewModel 2class PodcastShowDetailViewModel @Inject constructor( 3 @ApplicationContext context: Context, 4 savedStateHandle: SavedStateHandle, 5 podcastsRepository: PodcastsRepository, 6 getCurrentlyPlayingEpisodePlaybackStateUseCase: GetCurrentlyPlayingEpisodePlaybackStateUseCase 7) : ViewModel() { 8 private val stateProducer = 9 viewModelScope.podcastShowDetailStateProducer( 10 showId = savedStateHandle[ 11 MusifyNavigationDestinations.PodcastShowDetailScreen.NAV_ARG_PODCAST_SHOW_ID 12 ]!!, 13 countryCode = context.countryCode, 14 podcastsRepository = podcastsRepository, 15 getCurrentlyPlayingEpisodePlaybackStateUseCase = getCurrentlyPlayingEpisodePlaybackStateUseCase 16 ) 17 18 val state = stateProducer.state 19 val actions = stateProducer.accept 20}

The state producer is a pure function that takes the state production inputs and user actions and converts it to state following the official state production guide:

  1. Inputs: Data sources belong here.
  2. Pipeline assembly and initialization: Though varied, they fall into 2 distinct categories:
    1. Application driven: A conversion of the aforementioned inputs to a List of Flow<Mutation<State>> instances.
    2. User driven: The actions a user can perform to cause state changes. I model this as a Flow<Action> -> Flow<Mutation<State>>
  3. Output: A UI state representation of the state production pipeline.
1// Inputs are explicitly passed as function arguments 2fun CoroutineScope.podcastShowDetailStateProducer( 3 showId: String, 4 countryCode: String, 5 podcastsRepository: PodcastsRepository, 6 getCurrentlyPlayingEpisodePlaybackStateUseCase: GetCurrentlyPlayingEpisodePlaybackStateUseCase, 7) = actionStateFlowProducer<PodcastShowDetailAction, PodcastShowDetailUiState>( 8 // The singular output of state production 9 initialState = PodcastShowDetailUiState( 10 ... 11 ), 12 // Inputs as sources of state change 13 mutationFlows = listOf( 14 ... 15 ), 16 // User actions as sources of state change 17 actionTransform = { actions: Flow<PodcastShowDetailAction> -> 18 ... 19 } 20) 21

Let's break down the snippet above.

Inputs

The inputs for state production are the arguments of the function and are the data sources for state production:

1fun CoroutineScope.podcastShowDetailStateProducer( 2 showId: String, 3 countryCode: String, 4 podcastsRepository: PodcastsRepository, 5 getCurrentlyPlayingEpisodePlaybackStateUseCase: GetCurrentlyPlayingEpisodePlaybackStateUseCase, 6) : ActionStateFlowProducer<PodcastShowDetailAction, PodcastShowDetailUiState>

This is everything needed to produce the UI state wrapped up in a single function.

Pipeline assembly and initialization

The following describes the different sources of change in the state holder for the PodcaseShowDetailUiState, and how they are used to initialize the state production pipeline.

Application driven

These produce state changes regardless of user actions, and are caused by changes in application state. They have the following definition:

1mutationFlows: List<Flow<Mutation<State>>>

The state producer for the screen defined above has two application driven sources of change:

  • The currently playing episode use case.
  • The information for the podcast currently being viewed.
1 mutationFlows = listOf( 2 getCurrentlyPlayingEpisodePlaybackStateUseCase.playbackStateMutations(), 3 podcastsRepository.fetchShowMutations( 4 showId = showId, 5 countryCode = countryCode 6 ) 7 )
Currently Playing Episode Mutations

In the snippet below, the Flow from the use case is collected from and used to update the UI state each time the playback state changes.

1private fun GetCurrentlyPlayingEpisodePlaybackStateUseCase.playbackStateMutations(): Flow<Mutation<PodcastShowDetailUiState>> = 2 currentlyPlayingEpisodePlaybackStateStream 3 .mapToMutation { playbackState -> 4 when (playbackState) { 5 is GetCurrentlyPlayingEpisodePlaybackStateUseCase.PlaybackState.Ended -> copy( 6 isCurrentlyPlayingEpisodePaused = null, 7 currentlyPlayingEpisode = null 8 ) 9 10 is GetCurrentlyPlayingEpisodePlaybackStateUseCase.PlaybackState.Loading -> copy( 11 loadingState = PodcastShowDetailUiState.LoadingState.PLAYBACK_LOADING 12 ) 13 14 is GetCurrentlyPlayingEpisodePlaybackStateUseCase.PlaybackState.Paused -> copy( 15 currentlyPlayingEpisode = playbackState.pausedEpisode, 16 isCurrentlyPlayingEpisodePaused = true 17 ) 18 19 is GetCurrentlyPlayingEpisodePlaybackStateUseCase.PlaybackState.Playing -> copy( 20 loadingState = PodcastShowDetailUiState.LoadingState.IDLE, 21 isCurrentlyPlayingEpisodePaused = when (isCurrentlyPlayingEpisodePaused) { 22 null, true -> false 23 else -> isCurrentlyPlayingEpisodePaused 24 }, 25 currentlyPlayingEpisode = playbackState.playingEpisode 26 ) 27 } 28 }
PodcastsRepository fetch show Mutations

Performs the initial load in the state producer. It consumes a one-shot API from the PodcastRepository to produce its state change. Note that the one-shot API is represented as a flow to make it compatible with the state changes caused by getCurrentlyPlayingEpisodePlaybackStateUseCase.playbackStateMutations() as recommended in the official guide.

1private fun PodcastsRepository.fetchShowMutations( 2 showId: String, 3 countryCode: String 4) = flow<Mutation<PodcastShowDetailUiState>> { 5 val result = fetchPodcastShow( 6 showId = showId, 7 countryCode = countryCode, 8 ) 9 if (result is FetchedResource.Success) emit { 10 copy( 11 loadingState = PodcastShowDetailUiState.LoadingState.IDLE, 12 podcastShow = result.data 13 ) 14 } else emit { 15 copy(loadingState = PodcastShowDetailUiState.LoadingState.ERROR) 16 } 17}

User driven

There are two user actions that cause state changes. They are processed in the actionTransform lambda the has the following type signature:

1actionTransform: SuspendingStateHolder<State>.(Flow<Action>) -> Flow<Mutation<State>>

It is a function that takes a Flow of all the Action instances generated by the user, and creates a Flow of state changes. Its receiver also allows for suspending and reading the current state at a point in time, this communicates that state may change shortly after reading it.

1typealias suspendingStateHolder<State> = StateHolder<suspend () -> State> 2interface StateHolder<State : Any> { 3 val state: State 4}

Use of it typically involves splitting the Flow<Action> stream into independent streams of its subtypes. This splitting allows for Flow transformations applied to each subtype to be self contained. Retries can be debounced and delays induced without affecting the rest of the state production pipeline.

1 actionTransform = { actions -> 2 actions.toMutationStream { 3 when (val action = type()) { 4 // action.flow is an indpendent Flow<PodcastShowDetailAction.LoadAround> 5 is PodcastShowDetailAction.LoadAround -> action.flow.episodeMutations( 6 podcastsRepository = podcastsRepository 7 ) 8 // action.flow is an indpendent Flow<PodcastShowDetailAction.Retry> 9 is PodcastShowDetailAction.Retry -> action.flow.retryMutations( 10 podcastsRepository = podcastsRepository, 11 showId = showId, 12 countryCode = countryCode 13 ) 14 } 15 } 16 }
Paginated episode load Mutations

As the user scrolls the available episodes, it triggers requests to load more data around the user's current position. When the list is empty (the podcastQuery argument is null), it reads the start query from the existing UI state which is always non null, it then fetches episodes for the podcast and updates the UI state. This also allows for pagination to be stopped when the screen is left, and resume from exactly when it left of when resumed as seen in the following snippet:

1context(SuspendingStateHolder<PodcastShowDetailUiState>) 2private suspend fun Flow<PodcastShowDetailAction.LoadAround>.episodeLoadMutations( 3 podcastsRepository: PodcastsRepository 4): Flow<Mutation<PodcastShowDetailUiState>> = 5 map { it.podcastQuery ?: state().currentQuery } 6 .toTiledList( 7 // suspend and read where pagination should start from the current state. 8 startQuery = state().currentQuery, 9 queryFor = { copy(page = it) }, 10 fetcher = podcastsRepository::podcastsFor 11 ) 12 .mapToMutation { 13 copy(episodesForShow = it.distinctBy(PodcastEpisode::id)) 14 }
Load retry Mutations

If the initial load from PodcastsRepository.fetchShowMutations() fails, users may request a reload. When retrying, the state is first updated to signify that the UI is loading. After that, podcastsRepository.fetchShowMutations() for the initial load is reused. The use of the mapLatestToManyMutations (internally a flatMapLatest) extension below guarantees that only one retry coroutine exists at any one time, implementing cancellation and retry semantics implicitly and exclusively within the implementing state production function. All Mutation instances are also written inline without needing to create side effect abstractions.

1private fun Flow<PodcastShowDetailAction.Retry>.retryMutations( 2 podcastsRepository: PodcastsRepository, 3 showId: String, 4 countryCode: String 5): Flow<Mutation<PodcastShowDetailUiState>> = 6 // Uses flatMapLatest internally to make sure 7 // only one retry is in progress at any one time 8 mapLatestToManyMutations { 9 // First update the loading state to loading 10 emit { 11 copy( 12 loadingState = PodcastShowDetailUiState.LoadingState.Loading, 13 ) 14 } 15 // Call the initial load function again 16 emitAll( 17 podcastsRepository.fetchShowMutations( 18 showId = showId, 19 countryCode = countryCode 20 ) 21 ) 22 }
1inline fun <T, State> Flow<T>.mapLatestToManyMutations( 2 crossinline block: suspend FlowCollector<Mutation<State>>.(T) -> Unit 3): Flow<Mutation<State>> = 4 flatMapLatest { flow { block(it) } }

Output

The flows from the initialization of the state production pipeline are merged. This:

  1. Makes each Mutation Flow independent, debouncing in one flow will not affect other flows.
  2. Enforces strict concurrency in the same way a Mutex.lock() call would. Since each state change runs in the context of a Flow and has suspend semantics, the change is only applied atomically when the Mutation is invoked.

The output of the state production pipeline assembled above is one that meets the requirements defined above:

  1. Consist of pure functions: All arguments needed to produce state are explicitly passed to each state production function; the functions are all fully self contained.
  2. Static: Each function shown above is written in a static context, there is no enclosing class body where the functions can reach back into.
  3. Is side effect free: Task cancellation has to be modeled in the produced flow using Flow operators like mapLatest or FlatMapLatest. Same for retries.
  4. Require an observer: TheStateFlow produced has the sharing semantics of SharingStarted.WhileSubscribed(5_000) and may be changed. There are no unbounded fire-and-forget coroutines launched in an init block of a ViewModel as recommended by the official guide.
  5. Homogenous: All screens in the app follow the same state production templated. Actions and application state changes are reduced into an initial state.

Extra considerations

You should evaluate the following features of this UDF implementation to make sure it is compatible with your state production needs:

Pipeline featureConsiderationConsideration guidelines
Each Mutation is a new object that when applied also creates a new object.Consider your state production pipeline.This approach is justified if:
  • You have a highly concurrent screen with multiple data sources.
  • If it is a simple screen and consistency matters to your team.
  • Producing your state already allocates objects. IO operations for example typically require object creation when deserializing responses; creating a copy of state after this to add the response to state is insignificant.
This approach should not be used in highly specific low latency operations where the state consists of mostly primitive values to avoid primitive boxing costs.
State is in a single objectConsider your consumer of state.This is helpful to:
  • Consumers who do not have a diffing system but need to read multiple properties of state.
    • Selectors can then be applied to the monolith state to manually diff and selectively update interested components.
  • States that can be affected by multiple sources.
Each source can read the current state in its entirety, and apply the targeted change it needs. Multiple state producers can also be used to split up the pipeline as needed. Consult the official guide for details.
Functional reactive programmingConsider your team.This approach requires willingness to use functional programming paradigms. Imperative APIs may still be used, but they are isolated to the static state production function they are housed in. For example the flow { } builder API may be used, and a while loop with a delay can be used for polling or other loop based operations. An example of this can be seen in the state production for the search screen.

For:

  • Screens with a high number of contributions in response to user needs or product requirements
  • Product teams where ownership of a feature regularly changes hands
  • Product features where multiple teams contribute simultaneously

The benefits of this UDF implementation may justify its strictness. The major benefits are consistency, scalability and repeatability of the pipeline along with its strong concurrency and atomicity guarantees.

The same approach was used to produce state for all screens in the app listed below:

Here are some of their screenshots:

imageimageimage

The entire static state production pipeline for PodcastShowDetailUiState follows:

1sealed class PodcastShowDetailAction { 2 object Retry : PodcastShowDetailAction() 3 data class LoadAround(val podcastQuery: PodcastQuery?) : PodcastShowDetailAction() 4} 5 6data class PodcastShowDetailUiState( 7 val currentQuery: PodcastQuery, 8 val podcastShow: PodcastShow? = null, 9 val currentlyPlayingEpisode: PodcastEpisode? = null, 10 val isCurrentlyPlayingEpisodePaused: Boolean? = null, 11 val loadingState: LoadingState = LoadingState.LOADING, 12 val episodesForShow: TiledList<PodcastQuery, PodcastEpisode> = emptyTiledList() 13) { 14 enum class LoadingState { IDLE, LOADING, PLAYBACK_LOADING, ERROR } 15} 16 17fun CoroutineScope.podcastShowDetailStateProducer( 18 showId: String, 19 countryCode: String, 20 podcastsRepository: PodcastsRepository, 21 getCurrentlyPlayingEpisodePlaybackStateUseCase: GetCurrentlyPlayingEpisodePlaybackStateUseCase, 22) = actionStateFlowProducer<PodcastShowDetailAction, PodcastShowDetailUiState>( 23 initialState = PodcastShowDetailUiState( 24 currentQuery = PodcastQuery( 25 showId = showId, 26 countryCode = countryCode, 27 page = Page(offset = 0) 28 ) 29 ), 30 mutationFlows = listOf( 31 getCurrentlyPlayingEpisodePlaybackStateUseCase.playbackStateMutations(), 32 podcastsRepository.fetchShowMutations( 33 showId = showId, 34 countryCode = countryCode 35 ) 36 ), 37 actionTransform = { actions -> 38 actions.toMutationStream { 39 when (val action = type()) { 40 is PodcastShowDetailAction.LoadAround -> action.flow.episodeLoadMutations( 41 podcastsRepository = podcastsRepository 42 ) 43 44 is PodcastShowDetailAction.Retry -> action.flow.retryMutations( 45 podcastsRepository = podcastsRepository, 46 showId = showId, 47 countryCode = countryCode 48 ) 49 } 50 } 51 } 52) 53 54private fun GetCurrentlyPlayingEpisodePlaybackStateUseCase.playbackStateMutations(): Flow<Mutation<PodcastShowDetailUiState>> = 55 currentlyPlayingEpisodePlaybackStateStream 56 .mapToMutation { 57 when (it) { 58 is GetCurrentlyPlayingEpisodePlaybackStateUseCase.PlaybackState.Ended -> copy( 59 isCurrentlyPlayingEpisodePaused = null, 60 currentlyPlayingEpisode = null 61 ) 62 63 is GetCurrentlyPlayingEpisodePlaybackStateUseCase.PlaybackState.Loading -> copy( 64 loadingState = PodcastShowDetailUiState.LoadingState.PLAYBACK_LOADING 65 ) 66 67 is GetCurrentlyPlayingEpisodePlaybackStateUseCase.PlaybackState.Paused -> copy( 68 currentlyPlayingEpisode = it.pausedEpisode, 69 isCurrentlyPlayingEpisodePaused = true 70 ) 71 72 is GetCurrentlyPlayingEpisodePlaybackStateUseCase.PlaybackState.Playing -> copy( 73 loadingState = PodcastShowDetailUiState.LoadingState.IDLE, 74 isCurrentlyPlayingEpisodePaused = when (isCurrentlyPlayingEpisodePaused) { 75 null, true -> false 76 else -> isCurrentlyPlayingEpisodePaused 77 }, 78 currentlyPlayingEpisode = it.playingEpisode 79 ) 80 } 81 } 82 83private fun Flow<PodcastShowDetailAction.Retry>.retryMutations( 84 podcastsRepository: PodcastsRepository, 85 showId: String, 86 countryCode: String 87): Flow<Mutation<PodcastShowDetailUiState>> = 88 mapLatestToManyMutations { 89 emit { 90 copy( 91 loadingState = PodcastShowDetailUiState.LoadingState.Loading, 92 ) 93 } 94 emitAll( 95 podcastsRepository.fetchShowMutations( 96 showId = showId, 97 countryCode = countryCode 98 ) 99 ) 100 } 101 102context(SuspendingStateHolder<PodcastShowDetailUiState>) 103private suspend fun Flow<PodcastShowDetailAction.LoadAround>.episodeLoadMutations( 104 podcastsRepository: PodcastsRepository 105): Flow<Mutation<PodcastShowDetailUiState>> = 106 map { it.podcastQuery ?: state().currentQuery } 107 .toTiledList( 108 startQuery = state().currentQuery, 109 queryFor = { copy(page = it) }, 110 fetcher = podcastsRepository::podcastsFor 111 ) 112 .mapToMutation { 113 copy(episodesForShow = it.distinctBy(PodcastEpisode::id)) 114 } 115 116private fun PodcastsRepository.fetchShowMutations( 117 showId: String, 118 countryCode: String 119) = flow<Mutation<PodcastShowDetailUiState>> { 120 val result = fetchPodcastShow( 121 showId = showId, 122 countryCode = countryCode, 123 ) 124 if (result is FetchedResource.Success) emit { 125 copy( 126 loadingState = PodcastShowDetailUiState.LoadingState.IDLE, 127 podcastShow = result.data 128 ) 129 } else emit { 130 copy(loadingState = PodcastShowDetailUiState.LoadingState.ERROR) 131 } 132}

53