Composables

Floschu Control

Community

floschu

Use when implementing, debugging, or testing code that uses the floschu/control library. Triggers on tasks involving Store, Reducer, Actions, Mutations, Effects types from at.florianschuster.control; UDF state management in Kotlin Multiplatform projects; or migrating to or from the control library.

Overview

Repository
floschu/control
Publisher
floschu
Stars
56
Forks
9
Bundled files
0
License
Apache-2.0
Updated
Sep 15, 2026

Installation

tmp=$(mktemp -d)
git clone --depth 1 https://github.com/floschu/control.git "$tmp"
mkdir -p ~/.claude/skills
cp -R "$tmp/skills/floschu-control" ~/.claude/skills/floschu-control
rm -rf "$tmp"

Instructions

Control Library Skill

A skill for working with control - a Kotlin Multiplatform unidirectional data flow (UDF) library.

Overview

Control is a UI-independent state management library that separates business logic from view logic using the UDF pattern. Controllers have no dependency on views, making them easy to unit test.

Core Architecture
Action -> Mutator -> [0..n] Mutations -> Reducer -> New State
                          Action
      ┏━━━━━━━━━━━━━━━━━━━━━│━━━━━━━━━━━━━━━━━┓
      ┃                     │                 ┃
      ┃               ┏━━━━━▼━━━━━┓           ┃  side effect ┏━━━━━━━━━━━━━━━━━━━━┓
      ┃               ┃  mutator ◀────────────────────────────▶  service/usecase  ┃
      ┃               ┗━━━━━━━━━━━┛           ┃              ┗━━━━━━━━━━━━━━━━━━━━┛
      ┃                     │                 ┃
      ┃                     │ 0..n mutations  ┃
      ┃                     │                 ┃
      ┃               ┏━━━━━▼━━━━━┓           ┃
      ┃  ┌───────────▶┃  reducer  ┃           ┃
      ┃  │            ┗━━━━━━━━━━━┛           ┃
      ┃  │ previous         │                 ┃
      ┃  │ state            │ new state       ┃
      ┃  │                  │                 ┃
      ┃  │            ┏━━━━━▼━━━━━┓           ┃
      ┃  └────────────┃   state   ┃           ┃
      ┃               ┗━━━━━━━━━━━┛           ┃
      ┃                     │                 ┃
      ┗━━━━━━━━━━━━━━━━━━━━━│━━━━━━━━━━━━━━━━━┛
                            ▼
                          state

Installation

Add the dependency to your Kotlin Multiplatform project:

repositories {
    mavenCentral()
}

dependencies {
    implementation("at.florianschuster.control:control-core:$version")
}

Creating a Basic Controller

Step 1: Define Actions

Actions represent user intents or events that trigger state changes. Define them as a sealed interface:

sealed interface CounterAction {
    data object Increment : CounterAction
    data object Decrement : CounterAction
}
Step 2: Define Mutations (Private)

Mutations are internal state change descriptors. Keep them private to the controller:

private sealed interface CounterMutation {
    data object IncreaseValue : CounterMutation
    data object DecreaseValue : CounterMutation
    data class SetLoading(val loading: Boolean) : CounterMutation
}
Step 3: Define State

State is an immutable data class representing the current state:

data class CounterState(
    val value: Int = 0,
    val loading: Boolean = false
)
Step 4: Create the Controller

Use CoroutineScope.createController() to build the controller:

typealias CounterController = Controller<CounterAction, CounterState>

fun CoroutineScope.createCounterController(
    initialValue: Int = 0
): CounterController = createController(
    initialState = CounterState(value = initialValue),
    
    mutator = { action ->
        when (action) {
            is CounterAction.Increment -> flow {
                emit(CounterMutation.SetLoading(true))
                delay(500.milliseconds)
                emit(CounterMutation.IncreaseValue)
                emit(CounterMutation.SetLoading(false))
            }
            is CounterAction.Decrement -> flow {
                emit(CounterMutation.SetLoading(true))
                delay(500.milliseconds)
                emit(CounterMutation.DecreaseValue)
                emit(CounterMutation.SetLoading(false))
            }
        }
    },
    
    reducer = { mutation, previousState ->
        when (mutation) {
            is CounterMutation.IncreaseValue -> previousState.copy(value = previousState.value + 1)
            is CounterMutation.DecreaseValue -> previousState.copy(value = previousState.value - 1)
            is CounterMutation.SetLoading -> previousState.copy(loading = mutation.loading)
        }
    }
)

Key Components

Controller Interface

The core interface with two members:

interface Controller<Action, State> {
    fun dispatch(action: Action)  // Send actions to be processed
    val state: StateFlow<State>   // Observe state changes
}
Mutator

Transforms actions into a Flow of mutations. Has access to MutatorContext:

typealias Mutator<Action, Mutation, State> = 
    MutatorContext<Action, State>.(action: Action) -> Flow<Mutation>

interface MutatorContext<Action, State> {
    val currentState: State      // Access current state
    val actions: Flow<Action>    // Access actions flow for combining
}

Mutator patterns:

mutator = { action ->
    when(action) {
        // Emit no mutations
        is Action.NoOp -> emptyFlow()
        
        // Emit single mutation
        is Action.Simple -> flowOf(Mutation.DoSomething)
        
        // Emit multiple mutations (async operations)
        is Action.LoadData -> flow {
            emit(Mutation.SetLoading(true))
            val data = repository.fetchData()  // Suspend call
            emit(Mutation.SetData(data))
            emit(Mutation.SetLoading(false))
        }
        
        // Access current state
        is Action.Toggle -> flowOf(
            Mutation.SetEnabled(!currentState.isEnabled)
        )
    }
}
Reducer

Synchronously transforms mutations into new state:

typealias Reducer<Mutation, State> = 
    ReducerContext.(mutation: Mutation, previousState: State) -> State

reducer = { mutation, previousState ->
    when(mutation) {
        is Mutation.SetLoading -> previousState.copy(loading = mutation.loading)
        is Mutation.SetData -> previousState.copy(data = mutation.data)
        is Mutation.SetEnabled -> previousState.copy(isEnabled = mutation.enabled)
    }
}
Transformers

Transform flows of actions, mutations, or states:

// Initial action on start
actionsTransformer = { actions ->
    actions.onStart { emit(Action.InitialLoad) }
}

// Merge global streams
mutationsTransformer = { mutations ->
    merge(mutations, userSession.map { Mutation.SetSession(it) })
}

// Logging state changes
statesTransformer = { states ->
    states.onEach { println("New State: $it") }
}

EffectController

For one-off side effects (toasts, navigation, snackbars):

interface EffectController<Action, State, Effect> : Controller<Action, State> {
    val effects: Flow<Effect>  // Fan-out delivery (one emission per collector)
}
Creating an EffectController
sealed interface MyEffect {
    data class ShowToast(val message: String) : MyEffect
    data object NavigateBack : MyEffect
}

fun CoroutineScope.createMyController(): EffectController<MyAction, MyState, MyEffect> =
    createEffectController(
        initialState = MyState(),
        
        mutator = { action ->
            when (action) {
                is MyAction.Save -> flow {
                    emit(Mutation.SetLoading(true))
                    try {
                        repository.save(currentState.data)
                        emitEffect(MyEffect.ShowToast("Saved!"))
                        emitEffect(MyEffect.NavigateBack)
                    } catch (e: Exception) {
                        emitEffect(MyEffect.ShowToast("Error: ${e.message}"))
                    }
                    emit(Mutation.SetLoading(false))
                }
            }
        },
        
        reducer = { mutation, previousState ->
            // Can also emit effects in reducer
            when (mutation) {
                is Mutation.SetError -> {
                    emitEffect(MyEffect.ShowToast(mutation.error))
                    previousState.copy(error = mutation.error)
                }
                else -> previousState
            }
        }
    )

Configuration Options

ControllerLog

Configure logging for debugging:

createController(
    // ...
    controllerLog = ControllerLog.None,      // No logging (default)
    controllerLog = ControllerLog.Println,   // Print to console
    controllerLog = ControllerLog.Custom { message ->
        Timber.d(message)  // Custom logger
    }
)
ControllerStart

Control when the state machine starts:

createController(
    // ...
    controllerStart = ControllerStart.Lazy,        // Start on first access (default)
    controllerStart = ControllerStart.Immediately  // Start immediately on creation
)
Custom Dispatcher

Override the coroutine dispatcher:

createController(
    // ...
    dispatcher = Dispatchers.Default  // Or any custom dispatcher
)

Testing

Controller Testing

Test controllers directly by dispatching actions and asserting state:

class CounterControllerTest {
    
    @Test
    fun `increment increases value`() = runTest {
        val controller = createCounterController(initialValue = 0)
        
        controller.dispatch(CounterAction.Increment)
        advanceUntilIdle()
        
        assertEquals(1, controller.state.value.value)
    }
}
View Testing with Stubs

Use ControllerStub to test views in isolation:

@OptIn(TestOnlyStub::class)
class CounterViewTest {
    
    @Test
    fun `view displays correct state`() {
        val controller = scope.createCounterController().toStub()
        
        // Emit test state
        controller.emitState(CounterState(value = 42, loading = false))
        
        // Assert view displays "42"
    }
    
    @Test
    fun `button dispatches increment action`() {
        val controller = scope.createCounterController().toStub()
        
        // Simulate button click
        incrementButton.performClick()
        
        // Verify action was dispatched
        assertEquals(
            listOf(CounterAction.Increment),
            controller.dispatchedActions
        )
    }
}
EffectController Stub
@OptIn(TestOnlyStub::class)
class MyViewTest {
    
    @Test
    fun `shows toast on effect`() {
        val controller = scope.createMyController().toStub()
        
        // Emit test effect
        controller.emitEffect(MyEffect.ShowToast("Test message"))
        
        // Assert toast is shown
    }
}

View Integration

Jetpack Compose (Android)
@Composable
fun CounterScreen(
    controller: CounterController = viewModelScope.createCounterController()
) {
    val state by controller.state.collectAsState()
    
    Column {
        Text(text = "Count: ${state.value}")
        
        Button(
            onClick = { controller.dispatch(CounterAction.Increment) },
            enabled = !state.loading
        ) {
            Text("Increment")
        }
        
        Button(
            onClick = { controller.dispatch(CounterAction.Decrement) },
            enabled = !state.loading
        ) {
            Text("Decrement")
        }
        
        if (state.loading) {
            CircularProgressIndicator()
        }
    }
}
Collecting Effects
@Composable
fun MyScreen(controller: EffectController<MyAction, MyState, MyEffect>) {
    val context = LocalContext.current
    
    LaunchedEffect(controller) {
        controller.effects.collect { effect ->
            when (effect) {
                is MyEffect.ShowToast -> {
                    Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
                }
                is MyEffect.NavigateBack -> {
                    // Handle navigation
                }
            }
        }
    }
    
    // Rest of UI...
}

Best Practices

  1. Keep Mutations private: Mutations are implementation details of the controller
  2. Use immutable State: Always use data class with copy() for state updates
  3. Single source of truth: State should be the only source of truth for the view
  4. Side effects in mutator: Perform async operations (API calls, DB access) in the mutator
  5. Pure reducers: Reducers should be pure functions with no side effects
  6. Use EffectController for one-off events: Navigation, toasts, and snackbars should use effects
  7. Test controllers independently: Controllers have no view dependency, test them in isolation

Common Patterns

Loading/Error/Success Pattern
data class DataState(
    val data: List<Item> = emptyList(),
    val loading: Boolean = false,
    val error: String? = null
)

private sealed interface DataMutation {
    data object SetLoading : DataMutation
    data class SetData(val data: List<Item>) : DataMutation
    data class SetError(val error: String) : DataMutation
}

mutator = { action ->
    when (action) {
        is DataAction.Load -> flow {
            emit(DataMutation.SetLoading)
            try {
                val data = repository.loadData()
                emit(DataMutation.SetData(data))
            } catch (e: Exception) {
                emit(DataMutation.SetError(e.message ?: "Unknown error"))
            }
        }
    }
}

reducer = { mutation, previousState ->
    when (mutation) {
        is DataMutation.SetLoading -> previousState.copy(loading = true, error = null)
        is DataMutation.SetData -> previousState.copy(data = mutation.data, loading = false)
        is DataMutation.SetError -> previousState.copy(error = mutation.error, loading = false)
    }
}
Debounce Search Pattern
actionsTransformer = { actions ->
    actions.debounce { action ->
        if (action is SearchAction.Query) 300.milliseconds else Duration.ZERO
    }
}
Cancelling Previous Operations
mutator = { action ->
    when (action) {
        is SearchAction.Query -> flow {
            emit(SearchMutation.SetLoading(true))
            val results = searchService.search(action.query)
            emit(SearchMutation.SetResults(results))
            emit(SearchMutation.SetLoading(false))
        }.takeUntil(actions.filterIsInstance<SearchAction.Query>())
    }
}

changelog

See the changelog for versions.

FAQ

What does Floschu Control do?

Use when implementing, debugging, or testing code that uses the floschu/control library. Triggers on tasks involving Store, Reducer, Actions, Mutations, Effects types from at.florianschuster.control; UDF state management in Kotlin Multiplatform projects; or migrating to or from the control library.

Who made Floschu Control?

floschu (a community member) publishes it in the floschu/control repository on GitHub.

Is Floschu Control free?

Yes. Floschu Control is open source, published by floschu under the Apache-2.0 license.

What comes with Floschu Control?

Just its instructions. Everything the agent needs is in one SKILL.md file.

Which agents can use Floschu Control?

Any coding agent that reads SKILL.md files, including Claude Code, Codex, Cursor, GitHub Copilot and Gemini CLI. The agent loads the skill when a task matches its description.

What are agent skills?

An agent skill is a folder with a SKILL.md file: instructions that teach a coding agent how to do one kind of task, and optionally files it can read while it works. The agent keeps only the skill's name and description in mind and reads the rest when a task calls for it.

How are agent skills different from MCP servers?

An MCP server gives an agent new tools to call, code that runs somewhere and returns results. A skill gives it knowledge: how to approach a task with the tools it already has. Many agents use both.

How can my agent test my Android app?

Robomori lets coding agents create, start and control Android emulators and devices from one command, so they can install your app, take screenshots and check dark mode, font sizes and languages.

You're subscribed

The next lesson lands in on Wednesday.