SimpleMVI

repository·master·Indexed 41 days ago

https://github.com/arttttt/simplemvi

A 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.

Tokens
11.7K
Snippets
27
Records
44
Agent score
51%

What's inside SimpleMVI

  1. Overview of SimpleMVI

    master
    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.
  2. What is an Actor in SimpleMVI?

    master

    An Actor is the core component of the Store architecture responsible for implementing application logic. Its primary responsibilities are to:

    1. Accept Intent: Receive incoming actions from the user or system.
    2. Produce SideEffect: Emit one-time events (e.g., navigation, showing a toast).
    3. Generate new State: Update the application state based on processed intents.

    An Actor must be part of a Store and is managed by it. It operates within an ActorScope which provides access to the current state, a CoroutineScope for asynchronous work, and methods to reduce state, emit sideEffects, or dispatch new intents.

  3. What is a StorePlugin and how does it work?

    master

    A StorePlugin is an active participant that extends a Store without modifying its Actor. 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 scope provided in the Context.
    // Plugins are registered via createStore
    val store = createStore(
        name = storeName<MyStore>(),
        initialState = MyState(),
        actor = myActor,
        plugins = listOf(
            analyticsPlugin,
            rateLimitPlugin,
            persistedStatePlugin,
        ),
    )
  4. Implement logic using an Actor

    master

    Every Store requires an Actor. The Actor is the component responsible for containing all the core business logic and handling the Intent objects passed to the Store via accept().

    Threading and Background Work: When performing background work within an Actor, you should use coroutines with the provided CoroutineScope to ensure proper lifecycle management and threading.

  5. Understand the Store abstraction

    master

    The Store is the central component for implementing unidirectional data flow and a single source of truth in an application. It encapsulates application logic by managing a specific State, accepting Intent objects to trigger changes, and emitting SideEffect objects for one-time events.

    To use a Store, you must follow its lifecycle:

    1. Call Store.init() to prepare the store for use.
    2. Use Store.accept(intent) to pass intents into the system.
    3. Call Store.destroy() when the store is no longer needed to free up resources. Note: A store cannot be used after destroy() has been called.
    // Conceptual lifecycle usage
    val store = MyStore()
    store.init()
    
    store.accept(MyIntent.DoSomething)
    
    // When finished
    store.destroy()
  6. How SimpleMVI architecture works

    master

    SimpleMVI 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.

  7. Extend Store functionality with StorePlugin

    master

    A StorePlugin allows you to intercept and extend the behavior of a Store. 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 Intent before it reaches the Actor.
    • State Observation: Plugins are notified via onStateChanged whenever a new state is emitted.
    • Side Effect Observation: Plugins are notified via onSideEffect whenever a side effect is emitted.
    • Lifecycle Observation: Plugins can observe the store's destruction.
  8. Organize Stores, State, and Intents

    master

    To 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: AppStore containing 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 var for properties or MutableMap/MutableList inside 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).
  9. Core components of SimpleMVI

    master

    SimpleMVI 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 the Actor. Common uses include logging, analytics, intent transformation, and state persistence.
  10. Design Side Effects as Domain Events, not UI Actions

    master

    In SimpleMVI, SideEffect should 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
    }
  11. Implement an Actor using delegatedActor (Composition)

    master

    The delegatedActor function 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
        }
    )