Flux Architecture on top of Kotlin Flows
Flux on Flow (Flox, for short) is a library for building Android applications via using the unidirectional flow of the Flux pattern. Redux the most popular state management system in the web applications world is built on this architecture used by >50% of web applications. In times of declarative UI becoming popular (via Jetpack Compose) in the Android ecosystem, this ease of state management system leads to faster development, less boiler plate, easy debuggability and readability of large scale Android apps.
This library provides a few core tools that can be used to build applications of varying purpose and complexity. As declarative UI picks up in the Android ecosystem with popularity of Redux in the web world, Flox comes in with a state management pattern to ensure state of large scale apps is maintainable and deterministic.
-
State management
How to manage the state of your application using simple value types, and share state across many screens so that mutations in one screen can be immediately observed in another screen. -
Composition
How to break down large features into smaller components that can be extracted to their own, isolated modules and be easily glued back together to form the feature via Dagger multibinding. -
Side effects
How to let certain parts of the application talk to the outside world in the most testable and understandable way possible.
To build a feature using the Composable Architecture you define some types and values that model your domain:
- State: A type that describes the data your feature needs to perform its logic and render its UI.
- Action: A type that represents all of the actions that can happen in your feature, such as user actions, notifications, event sources and more.
- Reducer: A function that describes how to evolve the current state of the app to the next
state given an action. The reducer is also responsible for returning any effects that should be
run, such as API requests, which can be done by returning an
Effect
value. - Store: The runtime that actually drives your feature. You send all user actions to the store so that the store can run the reducer and effects, and you can observe state changes in the store so that you can update UI.
The benefits of doing this are that you will instantly unlock testability of your feature, and you will be able to break large, complex features into smaller domains that can be glued together.
As a basic example, consider a UI that is an architecture of a AI assistant application with chats, conversations inspired from ChatGPT. Each screen has it's viewmodel broken down into feature modules that expose Reducer<State, Action> and Jetpack Compose Screens that map into global state and action with a single Application State maintained inside a Store.
The Store
exposes a flow which emits the whole state of the app every time there's a change and a method to send actions that will modify that state. The State
is just a data class that contains ALL the state of the application. It also includes the local state of all the specific modules that need local state. More on this later.
The store interface looks like this:
interface Store<State, Action : Any> {
val state: StateFlow<State>
fun dispatch(vararg actions: A)
}
You can create your own AppState and child features states such as below. Any data class can be in AppState but as a modularisation example in this app, we take featureStates via Dagger Multibinding.
data class AppState(
val featureStates: Map<String, State>,
val applifecycle: Lifecycle.State = Lifecycle.State.INITIALIZED,
val userState: UserState = UserState.NoUser,
val networkState: NetworkState = NetworkState.Offline
) : State {
companion object {
const val stateKey = "appState"
}
}
And you can create a new store using:
createStore(
initialState = AppState(),
reducer = PullbackReducer(
innerReducer = reducers[AppState.stateKey] as Reducer<AppState, Action>,
mapToChildAction = { action -> action },
mapToChildState = { state -> state },
mapToParentAction = { action -> action },
mapToParentState = { state, _ -> state },
)
)
actions are sent like this:
store.dispatch(AppAction.BackPressed)
// or
store.dispatch(AppAction.BottomBarClicked(BottomTab.entries[index], navController))
// or
store.dispatch(AppAction.LoadConversations(
Resource.Success(
conversationDAO.getAll().map { it.toDomain() })
))
and views can subscribe like this:
val state: AppState by store.state.collectAsStateWithLifecycle()
// Propagate state down to child UI components
Actions are sealed classes extending ai.flox.state.Action which have multiple types an Action can be. The Action can be of Action.UI with componentIdentifiers or Action.Data with the attached Resource on which the action is called on. Any reducer can change the state off the app, based on any type of actions.
interface Action {
sealed interface UI : Action {
val componentIdentifier: ComponentIdentifier
interface RenderEvent : UI
interface ClickedEvent : UI
interface DragEvent : UI
interface LongPressEvent : UI
}
data class NavigateEvent(
val route: String
) : Action
sealed interface Data<T> : Action {
val resource: Resource<T>
interface CreateData<T> : Data<T>
interface LoadData<T> : Data<T>
interface UpdateData<T> : Data<T>
interface DeleteData<T> : Data<T>
}
data class Exception(val exception: kotlin.Exception) : Action
}
Reducers are classes that implement the following interface:
fun interface Reducer<State, Action> {
fun reduce(state: State, action: Action): ReduceResult<State, Action>
}
The idea is they take the state and an action and modify the state depending on the action and its payload.
In order to send actions asynchronously back to the store we use Effect
s. Reducers return an array of Effect
s.
We have 2 types of Effect
builders, withFlowEffect()
or noEffect()
. If a reducer wants to dispatch a flow of Action
s it can return a Flow<Action>
as ReduceResult
for async Action dispatch
else they can return noEffect
which returns an empty flow.
- Add the key on your local.properties file on the project (Android Studio) like the below.
- Run the project
- Jetpack Compose
- Coroutines
- Flow for asynchronous action dispatch and listen.
- Compose: Android’s modern toolkit for building native UI.
- Hilt Navigation Compose for injecting dependencies.
- Room: Constructs Database by providing an abstraction layer over SQLite to allow fluent database access.
- Hilt: Dependency Injection.
- Retrofit2 & OkHttp3: Construct the REST APIs and paging network data.\
- Moshi: A modern JSON library for Kotlin and Java.
If you'd like to contribute a library, please open a PR with a link to it!
Flox was built on a foundation of ideas started by other libraries, in particular Elm and Redux.
There are also many architecture libraries in the Android and Kotlin community. Each one of these has their own set of priorities and trade-offs that differ from Flox.