Fetching and displaying remote data is one of the most common tasks you may be assigned in programmer’s work. We might think the problem has already been solved, yet I still encounter mistakes causing bad experience to users, or rough, hand-crafted solutions for each remote call. This is far from optimal. Let’s review a process of modeling a remote request state in Kotlin.
The first attempt Link to heading
There aren’t many apps that don’t make some kind of network requests. We’ve all been here. Having coded data view and progress indicator, we just need to set up the initial state so that indicator animates and data view is hidden, then make a request and when data arrives – display it and hide indicator. Simple, right?
progressIndicator.visible = true
api.requestData()
.collect { data ->
progressIndicator.visible = false
dataView.visible = true
dataView.data = data
}
It’s simple, can’t argue with that, but this approach has a few flaws. Let’s tackle them one by one.
Supporting lazy scenario Link to heading
If a network request is fired upon a certain condition like a UI event,
the progress indicator must stay hidden until the condition is actually met.
That’s what onStart()
is for – moving progressIndicator.visible = true
line guarantees the progress indicator shows when the stream starts.
api.requestData()
.onStart {
progressIndicator.visible = true
}
// ...
Capturing state Link to heading
Since we want to have a remote call state model, introducing a data structure
seems like a good idea. Let’s utilize a data class
for that:
data class RemoteData(
val loading: Boolean,
val data: Data?,
)
Now we integrate the above model into our code:
api.requestData()
.map { data ->
RemoteData(loading = false, data = data)
}
.onStart {
emit(RemoteData(loading = true, data = null))
}
.collect { rd ->
progressIndicator.visible = data.loading
dataView.visible = !data.loading
dataView.data = rd.data
}
This code has an interesting property. It doesn’t touch your views before collection of data. It means you can separate state modeling from displaying it, achieving separation of concerns. Hurray!
Impossible state Link to heading
We’re still far from a robust model though. The current model accepts parameters
which aren’t exactly meaningful: RemoteData(loading = true, data = SomeData())
and RemoteData(loading = false, data = null)
. It’s called an impossible state,
and we want to avoid it. See the Making Impossible States Impossible talk by
Richard Feldman if you’re interested in hearing more.
There is a sealed class hierarchy feature in Kotlin which tells the compiler that a value is of one of specified types. Applying this technique results in the following:
sealed interface RemoteData {
object Loading : RemoteData
data class Success(val data: Data) : RemoteData
}
And this solves the impossible state issue.
Now a value of RemoteData
type can be either Loading
or Success
,
and nothing else.
What we can be improved further is supporting reuse by abstracting data type.
Leveraging generic type parameter lets using RemoteData
with virtually any type.
sealed interface RemoteData<out T> {
object Loading : RemoteData<Nothing>
data class Success<T>(val data: T) : RemoteData<T>
}
A few notes here:
Loading
is anobject
because a single instance is sufficient, as it doesn’t hold any data.- Also,
Loading
is an excellent place to useNothing
type, because it means that it never takes or provides any objects of type variableT
. - Lastly, the parameter
T
inRemoteData<out T>
hasout
variance, because it may provide aT
subtype. In other words, without it, the following assignment wouldn’t compile becauseNothing
isn’tString
type (it’s a subtype ofString
, and of every other class in Kotlin):
val rd: RemoteData<String> = RemoteData.Loading
Adding NotAsked
state and handling Failure
s
Link to heading
While we don’t always need NotAsked
state, it’s useful in case a remote
request is fired upon a certain event that doesn’t occur right after entering
a particular screen.
But we should always handle the error path.
Failure
is very similar to Success
in terms of implementation. A common
pitfall here is to specify Throwable
as an upper bound for Error
type.
It doesn’t make much sense. While you could wrap Throwable
object with
Failure
class without such limitation, you might want to pass a simple
String
message instead – your UI doesn’t necessarily need to know this is
Throwable
if handling error is about displaying a message on a screen, right?
sealed interface RemoteData<out Error, out Data> {
object NotAsked : RemoteData<Nothing, Nothing>
object Loading : RemoteData<Nothing, Nothing>
data class Failure<T>(val error: T) : RemoteData<T, Nothing>
data class Success<T>(val data: T) : RemoteData<Nothing, T>
}
Integration with a stream library should lift the emitted values into
RemoteData
context, meaning a lifted stream should start its emission with
Loading
, and then finish with either Success
or Failure
.
We could do it like this:
fun <T> Flow<T>.remotify() = this
.map { data -> Success(data) }
.onStart { emit(Loading) }
.catch { error -> Failure(error) }
And now the usage:
api.requestData()
.remotify()
.collect { rd ->
progressIndicator.visible = rd is Loading
errorView.visible = rd is Failure
errorView.message = (rd as? Failure<String>).message
dataView.visisble = rd is Success
dataView.data = (rd as? Success<Data>).data
}
We came to the point where the logic of loading remote data into a view is separated, ensuring consistent behavior throughout the application, maintaining clear semantics and disallowing some common mistakes. I believe this is a step towards better experience – for both programmers and end-users.
RemoteData is the name used in Elm implementation. This article is heavily inspired by both the Elm RemoteData library and the original blog post by Kris Jenkins.
You can use my port of the RemoteData library for Kotlin here.
This post was featured in Kotlin Weekly #344