Skip to main content

Get Started with FlowMVI

In this guide, you can learn everything you need to get started and we'll build a feature with UI in 10 minutes.

Step 0: Understand the primary concepts

First of all, here's how the library works:

  • Stores are classes that respond to events, called Intents, and update their State.
    • Responding to Intents is called reducing.
  • You add functionality to Stores using Plugins, which form a Pipeline.
  • Clients subscribe to Stores to render their State and consume side-effects, called Actions.
  • States, Intents, and Actions together form a Contract.
Show the diagram
Chart depicting FlowMVI Plugin ChainChart depicting FlowMVI Plugin Chain

Step 1: Configure the library

1.1: Add dependencies Maven Central

[versions]
flowmvi = "< Badge above 👆🏻 >"

[dependencies]
# Core KMP module
flowmvi-core = { module = "pro.respawn.flowmvi:core", version.ref = "flowmvi" }
# Test DSL
flowmvi-test = { module = "pro.respawn.flowmvi:test", version.ref = "flowmvi" }
# Compose multiplatform
flowmvi-compose = { module = "pro.respawn.flowmvi:compose", version.ref = "flowmvi" }
# Android (common + view-based)
flowmvi-android = { module = "pro.respawn.flowmvi:android", version.ref = "flowmvi" }
# Multiplatform state preservation
flowmvi-savedstate = { module = "pro.respawn.flowmvi:savedstate", version.ref = "flowmvi" }
# Remote debugging client
flowmvi-debugger = { module = "pro.respawn.flowmvi:debugger-plugin", version.ref = "flowmvi" }
# Essenty (Decompose) integration
flowmvi-essenty = { module = "pro.respawn.flowmvi:essenty", version.ref = "flowmvi" }
flowmvi-essenty-compose = { module = "pro.respawn.flowmvi:essenty-compose", version.ref = "flowmvi" }

1.2: Set up the IDE

FMVI comes with an IDE plugin that provides lint checks and templates to generate code. Consider installing it to make the amount of boilerplate you write minimal.

Step 2: Choose your style

FlowMVI supports both MVI (strict model-driven) and MVVM+ (functional, lambda-driven) styles.

  • Model-driven means that you create an MVIIntent subclass for every event that happens, and the store decides how to handle it. Model-driven intents are recommended to take full advantage of Plugins and are used in the examples below.
  • Functional means that you invoke functions which contain your business logic, and then send the logic for processing to the store.
See this section if you can't decide

It's preferable to choose one style and use it throughout your project. Each style has its own pros and cons, so choosing can be hard. So please consider the following comparison:

MVI style:

Pros 👍Cons 👎
Greater separation of concerns as intent handling logic is strictly contained in the store's scopeBoilerplatish: some intents will need to be duplicated for multiple screens, resulting in some amount of copy-paste
Verbose and readable - easily understand which intent does what judging by the contractHard to navigate in the IDE. You have to jump twice: first to the declaration, and then to usage, to see the code of the intent
Intents can be decomposed into sealed families, subclassed, delegated, have properties or functionsClass explosion - class for every event may result in 50+ model classes per screen easily
Intents can be re-sent inside the store - by sending an intent while handling another intentSealed classes work worse for some platforms, for example, in Swift, Enums are not used and names are mangled

MVVM+ style:

Pros 👍Cons 👎
Elegant syntax - open a lambda block and write your logic there. Store's code remains cleanYou have to use ImmutableStore interface to not leak the store's context
Easily navigate to and see what an intent does in one clickLambdas are less performant than regular intents
Easier to support on other platforms if handled correctly (not exposing store's logic in platform code)Some Plugins will become useless, such as logging/time travel/analytics
Get rid of all Intent classes entirely, avoid class explosionIntents cannot be composed, delegated and organized into families

If you decide to use the MVVM+ style, then your Intents will look like this:

fun onItemClick(item: Item) = store.intent {
updateState {
copy(selectedItem = item)
}
}
  • If you use MVVM+ style, you must install the reduceLambdas Plugin to make the store handle your intents. Plugins are explained below.
  • With MVVM+ style, consider using the ImmutableStore and ImmutableContainer interfaces that won't let external code send intents. This will prevent leaking the context of the Store to subscribers.

Step 3: Describe your Contract

Type fmvim in your editor to let the IDE Plugin generate a Contract for you:

// States
sealed interface CounterState : MVIState {
data object Loading : CounterState
data class Error(val e: Exception) : CounterState
data class DisplayingCounter(val counter: Int) : CounterState
}

// MVI Style Intents
sealed interface CounterIntent : MVIIntent {

data object ClickedNext : CounterIntent

data class ChangedCounter(val value: Int) : CounterIntent

data class GrantedPermission(val granted: Boolean, val permission: String) : CounterIntent
}

// MVVM+ Style Intents
typealias CounterIntent = LambdaIntent<CounterState, CounterAction>

// Side-effects
sealed interface CounterAction : MVIAction {

data class ShowMessage(val message: String) : CounterAction
}
  • If your store does not have a State, you can use an EmptyState object provided by the library.
  • If your store does not have side effects, use Nothing in place of the side-effect type.
Click if you need help defining a contract

Describing the contract first makes building the logic easier because this helps make it declarative. To define your contract, ask yourself the following:

  1. What can be shown at what times? Can the page be empty? Can it be loading? Can errors happen? - this will define your state family.
  2. What elements can be shown on this screen, for each state? - these will define your state properties.
  3. What can the user do on this screen? What can happen in the system? - these will be your Intents.
  4. In response to given intents, what one-time events may happen? - these are Actions. Keep in mind that side-effects are not the best way to manage your business logic. Try to make your UI/platform logic stateful first, and resort to side-effects only if a third-party API you're working with is imperative (such as Android SDK or View system).
  • The MVIState is what should be displayed or used by the UI layer. Whenever the state changes, update all of your UI with the current properties of the state.
    • Do not make your state mutable. Because FlowMVI uses StateFlows under the hood, your state changes won't be reflected if you mutate your state using vars or by using mutable properties such as MutableLists. Use copy() of the data classes to mutate your state instead. Even if you use Lists as the value type, for example, make sure those are new lists and not just MutableLists that were upcasted.
    • It's okay to copy the state often, modern devices can handle a few garbage collections.
  • The MVIIntent is an action that the user or the subscriber takes, for example: clicks, system broadcasts and dialog button presses.
  • The MVIAction is a one-off event that should happen in the UI or that the subscriber should handle.
    • Examples include snackbars, popup messages, sounds and so on.
    • Prefer using state instead of events if you are able to always know the outcome. Read more here.
      • Do not confuse States with Actions! Actions are one-off, "fire and forget" events that cannot be tracked after being sent.
      • Actions are sent and received sequentially.
    • Intents are sent to the Store. Actions are sent from the Store.
      • Actions are not strictly guaranteed to be received by the subscriber, so do not use them for crucial elements of the logic.

Step 4: Create your Store

You'll likely want to:

  1. Provide some dependencies for the Store to use, and
  2. Create additional functions instead of just putting everything into the Store's builder.

The best way to do this is to create a class that acts as a simple wrapper for your Store. By convention, it is usually named Container.

Generate a Container using the IDE plugin by typing fmvic:

private typealias Ctx = PipelineContext<CounterState, CounterIntent, CounterAction>

class CounterContainer(
private val repo: CounterRepository,
) : Container<CounterState, CounterIntent, CounterAction> {

override val store = store(initial = CounterState.Loading) {

}

// custom function
private fun Ctx.produceState(timer: Int) = updateState { DisplayingCounter(timer) }
}

Use lazyStore function to create a Store lazily if you don't plan to use it right away.

Step 5: Configure your store

To get started, you only need to set the debuggable parameter - it enables a lot of additional features like logging and validations.

Call configure inside your Store builder to change its settings:

val store = store<CounterState, CounterIntent, CounterAction>(initial = CounterState.Loading) {

configure {
debuggable = BuildFlags.debuggable
name = "CounterStore"
}
}
See this section for full explanation of the properties

Here are all of the configurable settings with defaults assigned:

configure {
debuggable = false
name = null
parallelIntents = false
coroutineContext = EmptyCoroutineContext
actionShareBehavior = ActionShareBehavior.Distribute()
onOverflow = BufferOverflow.DROP_OLDEST
intentCapacity = Channel.UNLIMITED
stateStrategy = StateStrategy.Atomic(reentrant = true)
allowIdleSubscriptions = false
logger = if (debuggable) PlatformStoreLogger else NoOpStoreLogger
verifyPlugins = debuggable
}
  • debuggable - Setting this to true enables additional store validations and debug logging. The store will check your subscription events, launches/stops, and Plugins for validity, as well as print logs to the system console.
  • name - Set the future name of the store. Needed for debug, logging, comparing and injecting stores, analytics.
  • parallelIntents - Declare that intents must be processed in parallel. Intents may still be dropped according to the onOverflow param.
  • coroutineContext - A coroutine context override for the store. This context will be merged with the one the store was launched with (e.g. viewModelScope). All store operations will be launched in that context by default.
  • actionShareBehavior - Define how the store handles and sends actions. Choose one of the following:
    • Distribute - send side effects in a fan-out FIFO fashion to one subscriber at a time (default).
    • Share - share side effects between subscribers using a SharedFlow. If an event is sent and there are no subscribers, the event will be lost!
      • Restrict - Allow only one subscription event per whole lifecycle of the store. If you want to subscribe again, you will have to re-create the store.
      • Disable - Disable side effects.
  • onOverflow - Designate behavior for when the store's intent queue overflows. Choose from:
    • BufferOverflow.SUSPEND - Suspend on buffer overflow.
    • BufferOverflow.DROP_OLDEST - Drop the oldest value in the buffer on overflow, add the new value to the buffer, do not suspend (default).
    • BufferOverflow.DROP_LATEST - Drop the latest value that is being added to the buffer right now on buffer overflow (so that buffer contents stay the same), do not suspend.
  • intentCapacity - Designate the maximum capacity of store's intent queue. This should be either:
    • A positive value of the buffer size
    • Channel.UNLIMITED - unlimited buffer (default)
    • Channel.CONFLATED - A buffer of 1
    • Channel.RENDEZVOUS - Zero buffer (all events not ready to be processed are dropped)
    • Channel.BUFFERED - Default system buffer capacity
  • stateStrategy - Strategy for serializing state transactions. Choose one of the following:
    • Atomic(reentrant = false) - Enables transaction serialization for state updates, making state updates atomic and suspendable. Synchronizes state updates, allowing only one client to read and/or update the state at a time. All other clients that attempt to get the state will wait in a FIFO queue and suspend the parent coroutine. For one-time usage of non-atomic updates, see updateStateImmediate. Recommended for most cases.
    • Atomic(reentrant = true) - Same as above, but allows nested state updates without causing a deadlock, like this: updateState { updateState { } }. This strategy is 15x slower than other options, but still negligible for managing UI and other non-performance-critical tasks. This is the default.
    • Immediate - 2 times faster than atomic with no reentrancy, but provides no state consistency guarantees and no thread-safety. Equivalent to always using updateStateImmediate.
  • allowIdleSubscriptions - A flag to indicate that clients may subscribe to this store even while it is not started. If you intend to stop and restart your store while the subscribers are present, set this to true. By default, will use the opposite value of the debuggable parameter (true on production).
  • logger - An instance of StoreLogger to use for logging events. By default, the value is chosen based on the debuggable parameter:
    • PlatformStoreLogger that logs to the primary log stream of the system (e.g. Logcat on Android).
    • NoOpStoreLogger - if debuggable is false, logs will not be printed.

Step 6: Install Plugins

Everything in FlowMVI is a Plugin. This includes handling errors and even reducing intents.

One Plugin almost every Store needs is the reduce Plugin. Install it when building your store:

override val store = store(Loading) {
configure { /* ... */ }

reduce { intent ->
when (intent) {
is ChangedCounter -> updateState<_, DisplayingCounter> {
copy(intent.newValue)
}
}
}
}

Every Plugin method has a special receiver called PipelineContext. It's like an "environment" the Store runs in, and gives you access to everything you need:

  • updateState { } - update the state of the store using the return value. Code in the block is thread-safe.
  • withState { } - grab the state thread-safely and use it, but do not change it.
  • action() - send a side-effect to subscribers
  • intent() - re-send and delegate to another intent
  • config - use the store's configuration, for example, to issue log calls: log { "initial state = ${config.initial}" }. It will only print if debuggable is true by default.

There are many other pre-made Plugins. Check out the Plugins page to learn about all of them.

Step 7: Start your store

Provide a coroutine scope with a lifecycle that matches the duration your Store should be accepting Intents and running background jobs.

  • On Android, this will likely be a viewModelScope.
  • On Desktop w/Compose, you can use rememberCoroutineScope().
  • On iOS, provide a scope manually or through a library.

Automatically:

fun counterStore(scope: CoroutineScope) = store(initial = Loading, scope = scope) { /* ... */ }

Manually

val scope = CoroutineScope()
val store = counterStore()

// start
val lifecycle = store.start(scope)

// stop
scope.cancel()

// or to keep the scope alive
lifecycle.close()
Don't forget to start your Store

Store will do nothing unless it is started using the start(scope: CoroutineScope) function or a scope is provided as a parameter to the builder.

tip

Store can be started and stopped as many times as you want. It will clean up everything except its state after itself.

Step 8: Subscribe to your Store

The way you do this varies a lot based on what you will use the Store for and your app's UI framework, if any. In this example, we'll subscribe using Compose to a Store made for managing UI state.

Type fmvis to generate a new composable screen using the IDE plugin:

@Composable
fun CounterScreen(
container: CounterContainer = DI.inject(),
) = with(container.store) {

val state by subscribe { action ->
when (action) {
is ShowMessage -> {
/* ... */
}
}
}

CounterScreenContent(state)
}

@Composable
private fun IntentReceiver<CounterIntent>.CounterScreenContent(state: DisplayingCounterState) {
/* ... */
}

Next Steps

That's it! You have set up FlowMVI in ~100 lines of code.

Now you can start using the features of the library to write scalable business logic with Plugins.

Continue learning by reading these articles:

  1. Learn how to install and create Plugins.
  2. Learn how to use FlowMVI with compose
  3. Learn how to persist and restore state
  4. Set up remote debugging and DI.
  5. Learn how to use FlowMVI on Android
  6. Get answers to common questions
  7. Explore the sample app for code examples