Skip to content

About

Reduce & Conquer is a state management architecture for Kotlin Multiplatform and Compose Multiplatform. A deterministic, boilerplate-free alternative to MVI and TEA patterns, it provides strict unidirectional data flow (UDF) and robust side-effect isolation in commonMain — purpose-built for scalable multiplatform app architecture.

Topics

Resources

Stars

1 star

Watchers

2 watching

Forks

Repository files navigation

Reduce & Conquer

Maven Central medium License: MIT

Reduce. Conquer. Repeat.


Navigation


About

Reduce & Conquer is a state management architecture for Kotlin Multiplatform and Compose Multiplatform. A deterministic, boilerplate-free alternative to MVI and TEA patterns, it provides strict unidirectional data flow (UDF) and robust side-effect isolation in commonMain — purpose-built for scalable multiplatform applications.

Layers

This repository contains the reference implementation and a comprehensive example demonstrating how the pattern integrates with Clean Architecture, using a Pokédex application built with Compose Multiplatform as the demonstration vehicle.


Installation

Add the dependency to your project:

// build.gradle.kts
commonMain.dependencies {
    implementation("io.github.numq:reduce-and-conquer:1.0.0")
}

Core Principles

Purity & Determinism

Business logic resides in pure functions without side effects. Given the same State and Command, you always get the same Transition — enabling time-travel debugging, reproducible testing, and formal verification.

Explicit Side Effects

All non-deterministic operations — network calls, database access, timers, reactive streams — are encapsulated in declarative Effects. The reduction core remains pure, while effects are managed by the runtime engine.

Unidirectional Data Flow

Data flows strictly in a single loop: Command → Reducer → (State, Events, Effects) → Command. This eliminates circular dependencies and makes system transitions completely traceable.

Mathematical Formalization

Every architectural decision is backed by mathematical foundations of state machines and monoidal transition composition.


Mathematical Core

At the heart of the pattern lies a single, pure transition function:

reduce :: (State, Command) → (State, [Event], [Effect])
  • Sequential Composability: State transitions over a stream of commands form a well-defined sequential fold.
  • Side-Effect Purity: Side effects are descriptions of work (Action, Stream, Cancel), not executions.
  • Separation of Concerns: State updates, external notifications (Event), and deferred work (Effect) do not pollute one another.

Universal Architecture Components

The pattern defines four core abstractions:

  1. State: The immutable representation of component state at any given point in time.
  2. Command: An intention to trigger a state transition or schedule an effect.
  3. Event: A one-time notification emitted during a transition (for UI side-channels like Snackbars, Toasts, or navigation).
  4. Effect: A structured asynchronous instruction (Action, Stream, Cancel) executed by the feature runtime.
graph LR
    Command["🗒️ Command"] --> Reducer["🧮 Reducer<br/>R: S×C→(S,Ev,Ef)"]
    State["📊 State"] --> Reducer
    Reducer --> NewState["📊 New State"]
    Reducer --> Events["📨 Events"]
    Reducer --> Effects["⚡ Effects"]
    Effects -->|execute| Command
    Events -->|notify| External["🌍 External Systems / UI"]
Loading

Universal Implementation

While this repository provides a Kotlin Multiplatform library, Reduce & Conquer is fundamentally platform-agnostic:

TypeScript
interface Transition<S, C, E> {
    state: S;
    events: E[];
    effects: Effect<C>[];
}

type Reducer<S, C, E> = (state: S, command: C) => Transition<S, C, E>;

const useFeature = <S, C, E>(
    initialState: S,
    reducer: Reducer<S, C, E>,
    onEvent?: (event: E) => void
) => {
    const processTransition = (transition: Transition<S, C, E>) => {
        transition.events.forEach(e => onEvent?.(e));
        transition.effects.forEach(effect => {
            // execute side-effects and dispatch results
        });
    };

    const [state, dispatch] = useReducer((currentState: S, command: C) => {
        const transition = reducer(currentState, command);
        setTimeout(() => processTransition(transition), 0);
        return transition.state;
    }, initialState);

    const execute = useCallback((command: C) => {
        dispatch(command);
    }, []);

    return {state, execute};
};
Swift
struct Transition<State, Command, Event> {
    let state: State
    let events: [Event]
    let effects: [Effect<Command>]
}

protocol Reducer {
    associatedtype State
    associatedtype Command
    associatedtype Event

    func reduce(state: State, command: Command) -> Transition<State, Command, Event>
}

@Observable
final class Feature<R: Reducer> {
    private(set) var state: R.State
    private let reducer: R
    
    var events: AsyncStream<R.Event> { /* ... */ }

    init(initialState: R.State, reducer: R) {
        self.state = initialState
        self.reducer = reducer
    }

    @MainActor
    func execute(_ command: R.Command) {
        let transition = reducer.reduce(state: state, command: command)
        self.state = transition.state
        
        transition.events.forEach { processEvent($0) }
        
        transition.effects.forEach { effect in
            Task {
                if let nextCommand = await processEffect(effect) {
                    execute(nextCommand)
                }
            }
        }
    }
}
Dart
class Transition<State, Command, Event> {
  final State state;
  final List<Event> events;
  final List<Effect<Command>> effects;

  Transition({
    required this.state, 
    this.events = const [], 
    this.effects = const []
  });
}

abstract class Reducer<State, Command, Event> {
  Transition<State, Command, Event> reduce(State state, Command command);
}

class Feature<State, Command, Event> {
  final Reducer<State, Command, Event> _reducer;
  final _stateController = BehaviorSubject<State>();
  
  Stream<State> get state => _stateController.stream;
  
  Feature(State initialState, this._reducer) {
    _stateController.add(initialState);
  }

  void execute(Command command) {
    final currentState = _stateController.value;
    final transition = _reducer.reduce(currentState, command);
    
    _stateController.add(transition.state);
    
    for (var effect in transition.effects) {
      _processEffect(effect);
    }
  }

  void _processEffect(Effect<Command> effect) async { /* ... */ }
}
Rust
pub struct Transition<S, C, E> {
    pub state: S,
    pub events: Vec<E>,
    pub effects: Vec<Effect<C>>,
}

pub trait Reducer: Send + Sync {
    type State;
    type Command;
    type Event;

    fn reduce(&self, state: Self::State, command: Self::Command) 
        -> Transition<Self::State, Self::Command, Self::Event>;
}

pub struct Feature<R: Reducer> {
    state: Arc<Mutex<R::State>>,
    reducer: Arc<R>,
    command_tx: mpsc::UnboundedSender<R::Command>,
}

impl<R> Feature<R> 
where 
    R: Reducer + 'static,
    R::State: Send + Clone,
    R::Command: Send,
    R::Event: Send,
{
    pub async fn execute(&self, command: R::Command) {
        let mut state_guard = self.state.lock().unwrap();
        let transition = self.reducer.reduce(state_guard.clone(), command);
        *state_guard = transition.state;
        drop(state_guard);

        for effect in transition.effects {
            self.process_effect(effect).await;
        }
    }
}
Go
type Transition[S any, C any, E any] struct {
    State   S
    Events  []E
    Effects []Effect[C]
}

type Reducer[S any, C any, E any] interface {
    Reduce(state S, command C) Transition[S, C, E]
}

type Feature[S any, C any, E any] struct {
    mu      sync.RWMutex
    state   S
    reducer Reducer[S, C, E]
    commands chan C 
}

func (f *Feature[S, C, E]) Execute(command C) {
    f.commands <- command
}

func (f *Feature[S, C, E]) loop() {
    for cmd := range f.commands {
        f.mu.Lock()
        transition := f.reducer.Reduce(f.state, cmd)
        f.state = transition.State
        f.mu.Unlock()

        for _, effect := range transition.Effects {
            go f.processEffect(effect)
        }
    }
}
Haskell
import Control.Monad.State
import Control.Monad.Writer
import Data.DList (DList, fromList, toList)

data SideEffects c e = SideEffects
  { outEvents  :: DList e
  , outEffects :: DList (Effect c)
  }

instance Semigroup (SideEffects c e) where
  (SideEffects e1 f1) <> (SideEffects e2 f2) = 
    SideEffects (e1 <> e2) (f1 <> f2)

instance Monoid (SideEffects c e) where
  mempty = SideEffects mempty mempty

type ReducerM s c e = c -> StateT s (Writer (SideEffects c e)) ()

emitEvent :: e -> StateT s (Writer (SideEffects c e)) ()
emitEvent e = tell $ SideEffects (fromList [e]) mempty

emitEffect :: Effect c -> StateT s (Writer (SideEffects c e)) ()
emitEffect f = tell $ SideEffects mempty (fromList [f])

runReducer :: ReducerM s c e -> s -> c -> (s, [e], [Effect c])
runReducer reducer s cmd = 
  let ((_, nextState), sideEffects) = runWriter (runStateT (reducer cmd) s)
  in (nextState, toList $ outEvents sideEffects, toList $ outEffects sideEffects)

Overview

classDiagram
    class Feature~State, Command, Event~ {
<<interface>>
+StateFlow~State~ state
+Flow~Event~ events
+execute(command: Command): suspend*
+close()
}

class ReducerFeature~State, Command, Event~ {
-CoroutineScope scope
-Reducer~State, Command, Event~ reducer
-Channel~Command~ _commands
-MutableSharedFlow~Event~ _events
-Atomic~Map~ Any, Job~~ jobs
-processEffect(effect: Effect)
-launchManaged(key: Any, block)
-cancelJob(key: Any)
}

class Reducer~State, Command, Event~ {
<<interface>>
+reduce(state: State, command: Command): Transition~State, Event~*
+transition(state: State): Transition~State, Event~
}

class Transition~State, Event~ {
+State state
+List~Event~ events
+List~Effect~ effects
+event(event): Transition
+events(vararg events): Transition
+effect(effect): Transition
+effects(vararg effects): Transition
}

class Effect {
<<sealedinterface>>
}

class Effect_Stream~Command~ {
+Any key
+Flow~Command~ flow
+Strategy strategy
+((Throwable) -> Command) ? fallback
}

class Effect_Action~Command~ {
+Any key
+(() -> Command) block
+((Throwable) -> Command) ? fallback
}

class Effect_Cancel {
+Any key
}

Effect <|.. Effect_Stream
Effect <|.. Effect_Action
Effect <|.. Effect_Cancel

Feature <|.. ReducerFeature
ReducerFeature --> Reducer
ReducerFeature --> Transition
ReducerFeature ..> Effect
Reducer --> Transition
Loading

State

An immutable data structure representing the current state of a component.

Command

A message sent to the feature to request a state modification or trigger an effect.

Event

A one-time notification for external subscribers (e.g. navigation actions, toasts, analytics).

Effect

A declarative description of an asynchronous side effect executed outside the reduction function.

Effect Types:

  • Stream<out Command>: Subscribes to an external Flow<Command> with automated lifecycle management.
  • Action<out Command>: Executes a one-off suspendable task with optional error fallback.
  • Cancel: Immediately terminates an ongoing Action or Stream by its key.

Effect Strategies (for Effect.Stream):

  • Sequential: Emits and processes commands sequentially (flow.collect).
  • Restart: Cancels previous processing when a new emission occurs (flow.collectLatest).

Feature

A lifecycle-aware container managing state emission, event propagation, and effect execution. It extendsAutoCloseable.

Creating a Feature:

// Instantiation
val feature = Feature<SomeState, SomeCommand, SomeEvent>(
    initialState = SomeState(),
    scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
    reducer = SomeReducer(),
    SomeCommand.LoadInitialData
)

// Delegation pattern (recommended)
class SomeFeature(reducer: SomeReducer) : Feature<SomeState, SomeCommand, SomeEvent> by Feature(
    initialState = SomeState(),
    scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
    reducer = reducer,
    SomeCommand.LoadInitialData
)

Reducer

A stateless functional interface:

interface Reducer<State, Command, Event in out> {
    fun reduce(state: State, command: Command): Transition<State, Event>
    fun transition(state: State) = Transition<State, Event>(state = state, events = emptyList(), effects = emptyList())
}

Reducer DSL:

  • transition(state): Initializes a Transition with empty event and effect lists.
  • action(key, fallback) { ... }: Creates an Effect.Action.
  • stream(key, flow, strategy, fallback): Creates an Effect.Stream.
  • cancel(key): Creates an Effect.Cancel.
class SomeReducer : Reducer<SomeState, SomeCommand, SomeEvent> {
    override fun reduce(state: SomeState, command: SomeCommand) = when (command) {
        is SomeCommand.HandleError ->
            transition(state).event(SomeEvent.NotifyError(command.throwable.message.orEmpty()))

        is SomeCommand.LoadData ->
            transition(state).effect(
                stream(
                    key = "data_stream",
                    flow = observeDataUseCase().map(SomeCommand::UpdateData),
                    fallback = { throwable -> SomeCommand.HandleError(throwable) }
                )
            )

        is SomeCommand.UpdateData ->
            transition(state.copy(data = command.data)).event(SomeEvent.ShowSuccess)

        is SomeCommand.PerformAction ->
            transition(state).effect(
                action(
                    key = "save_action",
                    block = {
                        val result = repository.save()
                        SomeCommand.OnSaved(result)
                    },
                    fallback = { throwable -> SomeCommand.HandleError(throwable) }
                )
            )

        is SomeCommand.Stop ->
            transition(state).effect(cancel(key = "data_stream"))
    }
}

Mathematical Foundation

Definition

Let $S$ be the set of states, $C$ the set of commands, $Ev$ the set of events, and $Ef$ the set of effects.

The state transition function is defined as: $$R: S \times C \rightarrow S \times \mathcal{P} (Ev) \times \mathcal{P} (Ef)$$

Where: $$R (s, c) = (s', ev, ef)$$

For convenience, we denote the state projection as: $$s \cdot c = \pi_S (R (s, c))$$

Composition & Associativity

Let $C^*$ be the free monoid of command sequences under concatenation ($\cdot$), with the empty sequence $\varepsilon$as identity: $$s \cdot \varepsilon = s$$ $$s \cdot (c_1 \cdot c_2) = (s \cdot c_1) \cdot c_2$$

Extending $R$ inductively over a sequence of commands $W = [c_1, c_2, \dots, c_n] \in C^$: $$R^(s, \varepsilon) = (s, \emptyset, \emptyset)$$ $$R^(s, [c_1]) = R (s, c_1) = (s_1, ev_1, ef_1)$$ $$R^(s, [c_1, c_2]) = (s_2, ev_1 \cup ev_2, ef_1 \cup ef_2) \quad \text{where } R (s_1, c_2) = (s_2, ev_2, ef_2)$$

By monoid action properties, grouping of consecutive sub-sequences is associative: $$R^(R_S^(s, W_1), W_2) = R^*(s, W_1 \cdot W_2)$$

This guarantees that folding commands in batches or sequentially produces the identical final state and equivalent accumulated collections of events and effects.

Conditional Commutativity

In general state management, commands are non-commutative (e.g. SetText("A") followed by Clear is not equivalent to Clear followed by SetText("A")).

However, two commands $c_1, c_2 \in C$ are commutative if and only if their state transitions commute and their generated outputs are order-independent: $$s \cdot c_1 \cdot c_2 = s \cdot c_2 \cdot c_1$$ $$\pi_{Ev, Ef} (R^(s, [c_1, c_2])) = \pi_{Ev, Ef} (R^(s, [c_2, c_1]))$$

When this condition is satisfied: $$R^(s, [c_1, c_2]) = R^(s, [c_2, c_1])$$

Conclusion

The state transition system forms a deterministic monoid action on state $S$, guaranteeing:

  • Reproducibility: Given an initial state $s_0$ and sequence $W$, the resulting state $s_n$ is strictly deterministic.
  • Testability: The core logic is verified without mocking asynchronous schedulers or UI frameworks.

Comparison with popular patterns

Model-View-Controller (MVC)

  • Contrast: Feature and Reducer eliminate monolithic controllers and mutable model state.
  • Advantage: Reducer is a pure function, keeping business logic strictly deterministic.

Model-View-ViewModel (MVVM)

  • Contrast: Unlike ViewModel where multiple methods can freely mutate state fields concurrently, Feature transitions can only be triggered via formal Command dispatch.
  • Advantage: Eliminates state race conditions and non-linear mutation chains.

Model-View-Intent (MVI) & Redux

  • Contrast: Strictly decouples one-shot UI notifications (Event) from asynchronous work (Effect).
  • Advantage: Side effects do not require complex external middleware (thunks/sagas); cancellation and reactive flows are natively handled through Kotlin Coroutines and typed keys.

The Elm Architecture (TEA)

  • Contrast: Native JVM/Kotlin adaptation designed specifically for Kotlin Coroutines, Kotlin Flow, and Compose.
  • Advantage: Direct support for long-running reactive flows via Effect.Stream alongside single-shot Effect.Action.

Actor Model

  • Contrast: Like an Actor, Feature isolates internal state and processes commands sequentially via a Kotlin Channel.
  • Advantage: Unlike traditional actors that scatter transition rules across message handlers, Reduce & Conquer concentrates all logic in a single pure reduction contract.

Clean Architecture

graph LR
    subgraph "Presentation Layer"
        View["View (Compose)"] --> Feature["Feature"]
        Feature["Feature"] --> Reducer["Reducer"]
    end

    subgraph "Domain Layer"
        Reducer --> UseCase["Use Case"]
        UseCase --> Entity["Entity"]
    end

    subgraph "Infrastructure Layer"
        UseCase --> Repository["Repository"]
        Repository --> External["Network / DB / Bluetooth"]
    end
Loading

Working with data flows

data class User(val id: String, val name: String)

data class UserState(val users: List<User> = emptyList())

sealed interface UserCommand {
    data object LoadUsers : UserCommand
    data class UpdateUsers(val users: List<User>) : UserCommand
    data class HandleError(val throwable: Throwable) : UserCommand
}

sealed interface UserEvent {
    data class NotifyError(val message: String) : UserEvent
    data class ShowSuccess(val message: String) : UserEvent
}

class UserReducer(
    private val observeUsersUseCase: ObserveUsersUseCase
) : Reducer<UserState, UserCommand, UserEvent> {
    override fun reduce(state: UserState, command: UserCommand) = when (command) {
        UserCommand.LoadUsers -> transition(state).effect(
            stream(
                key = "users_stream",
                flow = observeUsersUseCase().map(UserCommand::UpdateUsers),
                fallback = { throwable -> UserCommand.HandleError(throwable) }
            )
        )

        is UserCommand.UpdateUsers ->
            transition(state.copy(users = command.users)).event(UserEvent.ShowSuccess("Users updated"))

        is UserCommand.HandleError ->
            transition(state).event(UserEvent.NotifyError("Failed: ${command.throwable.message}"))
    }
}

class UserFeature(reducer: UserReducer) : Feature<UserState, UserCommand, UserEvent> by Feature(
    initialState = UserState(),
    scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
    reducer = reducer,
    UserCommand.LoadUsers
)

@Composable
fun UserScreen(feature: UserFeature) {
    val state by feature.state.collectAsState()

    LaunchedEffect(Unit) {
        feature.events.collect { event ->
            when (event) {
                is UserEvent.NotifyError -> showSnackbar(event.message)
                is UserEvent.ShowSuccess -> showSnackbar(event.message)
            }
        }
    }
}

Testing

Because business logic is encapsulated in pure functions, reducers can be verified synchronously without mocking coroutine scopes:

@Test
fun `load data produces stream effect`() {
    val reducer = UserReducer(observeUsersUseCase)
    val initialState = UserState()

    val transition = reducer.reduce(initialState, UserCommand.LoadUsers)

    assertEquals(initialState, transition.state)
    assertEquals(0, transition.events.size)
    assertEquals(1, transition.effects.size)
    assertIs<Effect.Stream<*>>(transition.effects.first())
}

Example (Proof of Concept)

Example

A cross-platform Pokédex application demonstrating Reduce & Conquer in practice.

Libraries Used in POC

  • Jetpack Compose Multiplatform
  • Kotlin Coroutines & Flow
  • Kotlinx AtomicFU
  • Kotlinx Datetime
  • Kotlinx Serialization
  • Koin Dependency Injection
  • Kotlin Multiplatform UUID

More examples

  • haskcore - A lightweight standalone Haskell IDE powered by Compose Desktop
  • GrokViewer - Application for viewing, filtering, and exporting Grok AI archives
  • StarsNoMore - GitHub repositories statistics and traffic summary tool
  • Klarity - Compose Desktop media player

License

This project is licensed under the MIT License - see the LICENSE file for details.

About

Reduce & Conquer is a state management architecture for Kotlin Multiplatform and Compose Multiplatform. A deterministic, boilerplate-free alternative to MVI and TEA patterns, it provides strict unidirectional data flow (UDF) and robust side-effect isolation in commonMain — purpose-built for scalable multiplatform app architecture.

Topics

Resources

Stars

1 star

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages