Key Concepts - Xlopec/Tea-bag GitHub Wiki

Tea-Bag isn't something new, it's a simple implementation of TEA architecture but in Kotlin. This library uses just the same ideas that TEA does, however, some entities might be named differently. What are these key concepts? Let's consider them.

Updater

Just as in the TEA an Updater in the Tea-Bag is nothing but a regular pure function that accepts a message and current state and produces an updated state with a set of commands to be executed later. Updater and Update declared as the following:

package io.github.xlopec.tea.core

public typealias Updater<M, S, C> = (message: M, state: S) -> Update<S, C>

public typealias Update<S, C> = Pair<S, Set<C>>

Any function with a matching signature function can serve as Updater. Consider the following example:

fun updater(delta: Int, accumulator: Int): Update<Int, Nothing> = (delta + accumulator) to setOf()

or a bit more realistic sample

package io.github.xlopec.reader.app.feature.filter

// Computes next FiltersState based on incoming FilterMessage
// onLoaded, onLoad and other signatures omitted for brevity
fun filtersUpdate(
    message: FilterMessage,
    state: FiltersState,
): Update<FiltersState, FilterCommand> =
    when (message) {
        is SuggestionsLoaded -> onLoaded(message.suggestions, state)
        is LoadSources -> onLoad(message.id, state)
        is SourcesLoadResult -> onSourcesLoadResult(message, state)
        is ToggleSourceSelection -> onToggleSelection(message.sourceId, state)
        is ClearSelection -> onClearSelection(state)
    }

Both aforementioned functions might be used as updaters since function signatures match the Updater definition and they are both pure. Being pure is an important characteristic of Updater since it allows us to parallelize computations of such functions or to memoize results and so on. We'll see how to do it a bit later.

Resolver

Ok, we know that Updater is a pure function that computes a new state given some message and state. The question is how to make say an HTTP request to a server? We can't do that with our Updater without breaking its purity.

The Resolver does just that, it consumes input from Updater (our Update data structure) and resolves commands to messages (remember that message is the first argument in our Updater). It has the following declaration:

public typealias Resolver<M, S, C> = (snapshot: Snapshot<M, S, C>, context: ResolveCtx<M>) -> Unit

public data class ResolveCtx<in M> internal constructor(
    public val sink: Sink<M>,
    public val scope: CoroutineScope,
)

public typealias Sink<T> = suspend (T) -> Unit

While resolving commands Resolver is allowed to produce side effects, which means it's the perfect place to make HTTP calls, query a database, etc.

ResolverCtx is just a structure that represents a resolver context. It consists of sink and scope that can be used to resolve effects. sink should be used to consume resolved messages while scope should be used for launching long-running/blocking operations and it's tied app lifecycle.

Important! Do not store references to [sink] or [scope] since they might change between method invocations

Here is the sample code that shows how to create a Resolver that queries API and returns a list of registered users:

class ApiResolver(
    private val httpClient: HttpClient
) : Resolver<Filter, Message> {
    override fun invoke(
        command: Filter,
        context: ResolveCtx<Message>
    ) = with(context) {

        val request = HttpRequestBuilder(
            scheme = URLProtocol.HTTPS.name,
            host = "example.org",
            path = "/v1/users"
        ) {
            parameters.append("registered", command.registeredOnly)
        }

        scope.launch {
            sink(UsersLoaded(httpClient.get(request).body<UsersResponse>().toUsersList()))
        }
        // or just the same but with help of DSL
        context effect UsersLoaded(httpClient.get(request).body<UsersResponse>().toUsersList())
    }
}

// Some app-wide Message interface

sealed interface Message

@JvmInline
value class UsersLoaded(val users: List<User>) : Message

// Some filter declaration

data class Filter(val registeredOnly: Boolean, ...)

Initializer

Initializer is just a specific case of Resolver. It provides initial state and set of commands to execute. It's signature looks like this:

public typealias Initializer<S, C> = suspend () -> Initial<S, C>

Component

Component wires all aforementioned entities together - Initializer, Updater, and Resolver. Together they form Component. The main function of any Component is state management. Component can be considered as a function with the following signature:

public typealias Component<M, S, C> = (messages: Flow<M>) -> Flow<Snapshot<M, S, C>>

It means that it accepts messages and returns snapshots. Snapshots describe the app's state at a particular moment in time. Snapshot is nothing but an immutable data class that holds the current state of the component in the moment of time, for example:

data class Snapshot<out M, out S, out C>(
    /**
     * Current state of a component
     */
    val currentState: S,
    /**
     * Set of commands to be resolved and executed
     */
    val commands: Set<C>,
    /**
     * Previous state of a component
     */
    val previousState: S,
    /**
     * Message that triggered state update
     */
    val message: M,
)

To produce Component instances we have the following factory function:

public fun <M, S, C> Component(
    initializer: Initializer<S, C>,
    resolver: Resolver<M, S, C>,
    updater: Updater<M, S, C>,
    scope: CoroutineScope,
    shareOptions: ShareOptions = ShareStateWhileSubscribed,
): Component<M, S, C> = ..

Aaaaand that's it. After we learned Tea-Bag key concepts we are ready to build something useful (or not) out of it

⚠️ **GitHub.com Fallback** ⚠️