SimpleMVI
repository·master·Indexed 41 days ago
https://github.com/arttttt/simplemviA lightweight, type-safe MVI (Model-View-Intent) library for Kotlin Multiplatform (KMP) applications. It provides a predictable unidirectional data flow for managing state and domain logic across Android, iOS, macOS, and Wasm JS using Kotlin Coroutines. Key components include the Store, Actor, Intent, State, Side Effect, and StorePlugin, with support for OOP, functional DSL, and composition-based actor implementations.
What's inside SimpleMVI
- SimpleMVI is a lightweight, type-safe MVI (Model-View-Intent) library designed for Kotlin Multiplatform (KMP) applications. It is platform-agnostic, supporting Android, iOS, macOS, and Wasm JS. The library enforces a unidirectional data flow and leverages Kotlin Coroutines for asynchronous operations.
What is an Actor in SimpleMVI?
masterAn
Actoris the core component of theStorearchitecture responsible for implementing application logic. Its primary responsibilities are to:- Accept
Intent: Receive incoming actions from the user or system. - Produce
SideEffect: Emit one-time events (e.g., navigation, showing a toast). - Generate new
State: Update the application state based on processed intents.
An
Actormust be part of aStoreand is managed by it. It operates within anActorScopewhich provides access to the current state, aCoroutineScopefor asynchronous work, and methods toreducestate, emitsideEffects, or dispatch newintents.- Accept
What is a StorePlugin and how does it work?
masterA
StorePluginis an active participant that extends aStorewithout modifying itsActor. Plugins can observe the store's lifecycle, transform or block intents, hydrate the initial state, and react to state changes or side effects.Key characteristics:
- Active Participation: Unlike traditional middleware, plugins can dispatch new intents, set state, and emit side effects using the provided
Context. - Composition: Multiple plugins compose in the order they are registered. This creates a pipeline where an earlier plugin can transform or block an intent before a later plugin even sees it.
- Threading: Callbacks are invoked synchronously on the thread driving the store operation. For background work, use the
scopeprovided in theContext.
// Plugins are registered via createStore val store = createStore( name = storeName<MyStore>(), initialState = MyState(), actor = myActor, plugins = listOf( analyticsPlugin, rateLimitPlugin, persistedStatePlugin, ), )- Active Participation: Unlike traditional middleware, plugins can dispatch new intents, set state, and emit side effects using the provided
Implement logic using an Actor
masterEvery
Storerequires anActor. TheActoris the component responsible for containing all the core business logic and handling theIntentobjects passed to theStoreviaaccept().Threading and Background Work: When performing background work within an
Actor, you should use coroutines with the providedCoroutineScopeto ensure proper lifecycle management and threading.Understand the Store abstraction
masterThe
Storeis the central component for implementing unidirectional data flow and a single source of truth in an application. It encapsulates application logic by managing a specificState, acceptingIntentobjects to trigger changes, and emittingSideEffectobjects for one-time events.To use a
Store, you must follow its lifecycle:- Call
Store.init()to prepare the store for use. - Use
Store.accept(intent)to pass intents into the system. - Call
Store.destroy()when the store is no longer needed to free up resources. Note: A store cannot be used afterdestroy()has been called.
// Conceptual lifecycle usage val store = MyStore() store.init() store.accept(MyIntent.DoSomething) // When finished store.destroy()- Call
How SimpleMVI architecture works
masterSimpleMVI is built on the principles of a Single Source of Truth and Immutable State. The architecture ensures a strict separation between the UI layer and the domain logic layer.
State management is centralized in a
Store, and all state changes must be performed through pure functions to maintain predictability.Extend Store functionality with StorePlugin
masterA
StorePluginallows you to intercept and extend the behavior of aStore. Plugins are notified of various lifecycle events and can participate in the intent processing pipeline.Plugin Capabilities:
- Intent Interception: Plugins can rewrite or drop an
Intentbefore it reaches theActor. - State Observation: Plugins are notified via
onStateChangedwhenever a new state is emitted. - Side Effect Observation: Plugins are notified via
onSideEffectwhenever a side effect is emitted. - Lifecycle Observation: Plugins can observe the store's destruction.
- Intent Interception: Plugins can rewrite or drop an
Threading model in SimpleMVI
masterSimpleMVI uses Kotlin Coroutines for concurrency. Developers should handle all background processing and long-running tasks within theActorusing coroutines to ensure the UI remains responsive.Organize Stores, State, and Intents
masterTo maintain a scalable codebase, follow these structural guidelines:
Keep Stores Focused
Avoid 'God Objects'. Each Store should have a single, well-defined responsibility.
- Good:
AuthStore,ProfileStore,SettingsStore. - Bad:
AppStorecontaining all application logic.
Use Flat, Immutable State
State should be easy to reason about and impossible to mutate directly.
- Good:
data class UserState(val isLoading: Boolean = false, val user: User? = null) - Bad: Using
varfor properties orMutableMap/MutableListinside the state class.
Design Clear Intents
Intents should represent explicit user actions or system events.
- Good:
UserIntent.Login(email, password),UserIntent.Logout. - Bad:
UserIntent.DoStuff(data).
- Good:
Core components of SimpleMVI
masterSimpleMVI consists of several key abstractions that work together to manage application logic:
- Store: The central component that manages the state and coordinates all other components.
- Actor: The business logic processor. It receives
Intents and produces state changes. - Intent: Represents user actions or system-driven events sent to the Store.
- State: An immutable data structure representing the current state of the application.
- Side Effect: Represents one-time events (e.g., navigation, showing a toast) that cannot be captured in the persistent state.
- StorePlugin: An optional extension point. Plugins can observe the Store's lifecycle and can rewrite or block
Intents before they reach theActor. Common uses include logging, analytics, intent transformation, and state persistence.
Design Side Effects as Domain Events, not UI Actions
masterIn SimpleMVI,
SideEffectshould represent domain events (what happened in the business logic) rather than UI actions (instructions on what the UI should do).Why this matters:
- Platform Independence: The same Store can run on Android, iOS, Desktop, or Web. The Store emits a domain event (e.g.,
LoginSucceeded), and each platform decides how to present it (e.g., a Toast on Android vs. an Alert on iOS). - Testability: You can test business logic by asserting that specific domain events were emitted without needing UI mocks.
- Separation of Concerns: The Domain Layer (Store/Actor) decides what happened; the UI Layer interprets how to show it.
Examples
✅ Good: Domain Events
sealed interface UserSideEffect { data object LoginSucceeded : UserSideEffect data class LoginFailed(val reason: String) : UserSideEffect data object SessionExpired : UserSideEffect }❌ Bad: UI Actions
sealed interface UserSideEffect { data object NavigateToHome : UserSideEffect // UI navigation data object ShowToast(val message: String) : UserSideEffect // UI presentation data object ShowLoadingDialog : UserSideEffect // UI state }sealed interface UserSideEffect { data object LoginSucceeded : UserSideEffect data class LoginFailed(val reason: String) : UserSideEffect data object SessionExpired : UserSideEffect }- Platform Independence: The same Store can run on Android, iOS, Desktop, or Web. The Store emits a domain event (e.g.,
Implement an Actor using delegatedActor (Composition)
masterThe
delegatedActorfunction allows you to explicitly compose intent handlers. This provides maximum flexibility and is the foundation for modular code organization.Features:
- Explicit handler registration.
- Handlers can be defined separately and composed.
- Highly compatible with KSP code generation.
When to use:
- When you need to compose handlers from different modules.
- When handlers are defined separately.
- Large projects with complex handler composition.
val counterActor = delegatedActor<CounterIntent, CounterState, CounterSideEffect>( initHandler = InitHandler { // Optional: initialization logic }, intentHandlers = listOf( intentHandler<CounterIntent, CounterState, CounterSideEffect, CounterIntent.Increment> { reduce { copy(count = count + 1) } }, intentHandler<CounterIntent, CounterState, CounterSideEffect, CounterIntent.Decrement> { reduce { copy(count = count - 1) } }, intentHandler<CounterIntent, CounterState, CounterSideEffect, CounterIntent.Reset> { reduce { CounterState() } sideEffect(CounterSideEffect.CounterReset) } ), destroyHandler = DestroyHandler { // Optional: cleanup logic } )