Floschu Control
Communityfloschu
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"tmp=$(mktemp -d)
git clone --depth 1 https://github.com/floschu/control.git "$tmp"
mkdir -p ~/.codex/skills
cp -R "$tmp/skills/floschu-control" ~/.codex/skills/floschu-control
rm -rf "$tmp"Install the Floschu Control agent skill from https://github.com/floschu/control/tree/develop/skills/floschu-control into this project's skills folder.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
- Keep Mutations private: Mutations are implementation details of the controller
- Use immutable State: Always use
data classwithcopy()for state updates - Single source of truth: State should be the only source of truth for the view
- Side effects in mutator: Perform async operations (API calls, DB access) in the mutator
- Pure reducers: Reducers should be pure functions with no side effects
- Use EffectController for one-off events: Navigation, toasts, and snackbars should use effects
- 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.