Kedux is a Kotlin-multiplatform implementation of redux that works on Android, iOS, MacOS, and JS utilizing Coroutines + Flow ❤️
NOTE: This library is currently in development preview. Please check back later when it's ready for release.
This library provides it's "best" API for each translated platform as much as possible. The following guides provide getting started for users of other platforms including common Kotlin code as a dependency.
Typescript Guide (TBD)
State in Kedux should be immutable. This means utilizing data class
, List
, Map
, and
all immutable constructs Kotlin provides.
The State
object should represent your application's state. Note: Kedux supports FracturedState
for apps that want to split up
state into multiple modules.
Say we have a simple state as:
data class Product(val id: Int, val name: String)
data class Location(val id: Int, val other: String, val product: Product? = null)
data class GlobalState(val name: String, val location: Location? = null)
// define an initial state (required)
// gets emitted on first selector subscription.
val initialState = GlobalState(name = "", location = null)
Next define a set of actions. These actions should be sealed class
type so we can do some magic.
sealed class StoreTestAction {
data class NameChange(val name: String) :
StoreTestAction()
data class LocationChange(val location: Location) :
StoreTestAction()
// objects are nice
object Reset : StoreTestAction()
}
Next, we define our global reducer for our state. Kedux supports reducers from global state, fractured state, and on specific action types automatically.
val sampleReducer = typedReducer<GlobalState, StoreTestAction> { state, action ->
// due to action sealed class, compiler can verify all action type args!
// under the hood, this inline function typedReducer verifies the action is the proper type
// before arriving here.
when (action) {
is StoreTestAction.NameChange -> state.copy(name = action.name)
is StoreTestAction.Reset -> state
is StoreTestAction.LocationChange -> state.copy(location = action.location)
}
}
As you would for Redux, construct your store somewhere accessible.
We recommend using dependency injection to provide your store in your app so it's easier to test.
val store = createStore(sampleReducer, initialState)
Store.loggingEnabled
: set this to log events to the native console as they come in. Preferrably this is set only on development
builds.
Selectors are pure Observable
functions that accept state and emit changes to the state.
Selectors only emit when their output is distinct.
Selectors only recompute when their upstream state is distinct.
Selectors emit on subscription. So dowstream observers will receive the latest state.
Selectors can be combined.
To observe changes on the store, subscribe to its state:
val nameSelector = createSelector<GlobalState, String> { state -> state.name }
// subscribe to store updates
store.select(nameSelector).onEach { name ->
// do something with name
}.launchIn(scope)
It's important to ensure you add the subscription to a CoroutineScope
,
so that you do not introduce memory leaks.
This library has a few features. TBD on full descriptions.
Store
is an object that exposes a state: Flow<State>
in which subscribers can listen to state changes, and an actions stream
action: Flow<Action>
that logs all actions coming through.
createStore
: creates the store with a global reducer
, initialState (required), and enhancer
(more on these later).
Store.dispatch
: asynchronously dispatches actions to the state
. Selection happens on the computationScheduler
,
and then returns the result object on the mainScheduler
thread of the platform.
Store.loggingEnabled
: a global value to turn on or off logging on the store for all actions and effects.
Note: Kotlin Native targets should be wary of frozen objects when passing between threads. By design, state should be
immutable in this library's constructs to prevent InvalidMutabilityException
errors.
Outside of plain objects, you can also dispatch
special objects on the Store
if you wish.
Supported types:
1
. Pair
- dispatches both actions on the store in order.
store.dispatch(MyAction() to MyAction2())
2
. Triple
- dispatches all three actions on the store in order.
store.dispatch(Triple(MyAction(), MyAction2(), MyAction3()))
3
. MultiAction
- dispatches 0 to N actions on the store in order.
store.dispatch(multipleActionOf(MyAction(), MyAction2(), MyAction3(), MyActionN()))
4
. NoAction
- store will not dispatch the action. Useful for Effects
that are silent, or within a when
returns that
return an Action type based on conditions and you want to ignore the action:
store.dispatch(when(name) {
"first" -> FirstNameChanged(name)
"middle" -> MiddleNameChanged(name)
"last" -> LastNameChanged(name)
else -> NoAction
})
5
. Action<T>
- actions based on a type argument to distinguish them. Rather instead of using plain Action data class
objects,
you can create actions as functions:
// no arguments or payload immediately create action (to get around passing `Unit` to `ActionCreator`)
val loadUsersAction = createAction("[Users] Load Users")
// use on store
store.dispatch(loadUsersAction)
// ActionCreator with `Int` argument, that returns an action with `Int` payload incremented by 1.
val loadUserAction = createAction("[Users] Load User by Id") { argument: Int -> argument + 1 }
// use on store
store.dispatch(loadUserAction(5))
// ActionCreator that accepts no arguments but allows payload return:
val loadUserActionDefault = createAction("[Users] Load User by Id Default") { 1 }
// use on Store
store.dispatch(loadUserActionDefault())
There are three main kinds of reducers.
anyReducer
: constructs a reducer on the whole global store, without specifying action type. This is useful when your reducer
consumes multiple action classes. You will need to handle default case in this instance.
typedReducer
(preferred): constructs a reducer that will only run when the Action
class type is of the type specified. So
that a safer consumption occurs. I.e. the reducer only executes when the action type is a subtype of the expected action type.
val sampleReducer = typedReducer<GlobalState, StoreTestAction> { state, action ->
when (action) {
is StoreTestAction.NameChange -> state.copy(name = action.name)
is StoreTestAction.Reset -> state
is StoreTestAction.LocationChange -> state.copy(location = action.location)
is StoreTestAction.NamedChanged -> state.copy(nameChanged = true)
is StoreTestAction.LocationChanged -> state
// using data classes, compiler doesn't need an `else` branch.
}
}
actionTypeReducer
: constructs a reducer that will only run when the Action.type
matches the type specified in the reducer.
This is useful for createAction
results by function and switching on the type you want to consume.
val sampleTypedReducer = actionTypeReducer { state: GlobalState, action: Action<SampleEnumType, out Any> ->
when (action.type) {
SampleEnumType.LocationChange -> state.copy(location = action.payload as Location?)
SampleEnumType.NameChange -> state.copy(name = action.payload as String)
SampleEnumType.Reset -> initialState
// use enum for action type tokens ensures compiler doesnt need an `else` branch.
}
}
combineReducers
: Combines multiple reducers to listen on the same state object.
Selectors are functions that are memoized with their input data and only recompute when the state changes. They are useful for heavy calculations such as retrieving an object out of a list by id, for example.
Creating a selector is easy:
// declare a global field, selectors are just functions
val fieldSelector = createSelector<GlobalState, Field> { state -> state.field }
// subscribe to the selector to gain new values
store.select(fieldSelector).onEach { value ->
// do something
}
.launchIn(scope)
Selectors can be composed. Each nested level only recomputes when its outer state changes. It's best practice to break up the composition into smaller pieces.
// avoid
val nameSelector = createSelector<GlobalState, Location?> { state -> state.location }
.compose { state -> state.product}
.compose { state -> state.name }
// preferred defining them top-level and chaining them, just in case you need more :)
val locationSelector = createSelector<GlobalState, Location?> { state -> state.location }
val productSelector = locationSelector.compose { state -> state.product }
val productNameSelector = productSelector.compose { state -> state.name }
By composing selectors in separate fields, they become more reusable.
Effects are Flow
chains that occur after an action is dispatched on the store, and return with
another action, set of actions (MultiAction
), or NoAction
.
To define an Effect
:
val getUsersEffect = createEffect<LoadUsers, UsersReceived> { actionObservable ->
actionObservable.flatMap { (userId) -> userService.getUsers(userId) }
.map { users -> UsersReceived(users) }
}
In this example, the Effect
responds to a LoadUsers
action, calls out to UserService
, and returns a UsersReceived
action, which the store dispatches out to a Reducer
to handle.
Pro Tip: Be careful of cyclical Effect
. If you have two separate effects consume and dispatch each other's effects,
you could run into a cycle that consumes your application and might cause it to freeze.
Now group the Effect
into an Effects
object:
val usersEffects = Effects(getUsersEffect)
An Effects
object manage the scoped lifecycle and binding to the Store
actions. They efficiently
group the bindings together into logical components.
Effects
are bound to the Store
in a couple of ways: globally and scoped.
Globally - bind to the Store
in global scope when the Store
is created:
store = createStore(...)
.also { usersEffects.bindTo(it) }
Or Scope Effect groupings at a smaller level, such as within a particular flow in your application:
val usersEffects = Effects(getUsersEffect, effect2, effectN)
// bind to store when object in scope
userEffect.bindTo(store)
// remove subscriptions to Store when out of scope.
userEffect.clearBindings()
Effects
can return multiple effects at a time in a fan-out fashion. This is very useful
when you want keep your actions pure, such as notifying a Reducer
of a loading state change, while another Reducer
receives the actual data.
val multipleDispatchEffect = createEffect<LocationChange, MultiAction> { change ->
change.map { (location) -> multipleActionOf(LocationChanged(location.other), LoadStatus.Done) }
}
All types specified in Store
are supported as return types in Effects
.
Enhancers enable you to transform an action as they come in and go to dispatch
. They enable
you to dispatch multiple actions outside the normal single-dispatch action.
createStore(reducer, initialState, enhancer = DevToolsEnhancer()) // just an example
combining enhancers: coming soon.
Fractured state is when we want to have our reducers only respond to state changes on a single field from the GlobalState
variable. This is accomplished using the FracturedState
object and special creation of our store:
store = createFracturedStore(
productReducer reduce Product(0, ""),
locationReducer reduce Location(0, "")
)
This method returns a Store<FracturedState>
with a few helper extensions to make usage cleaner.
FracturedState
is essentially a reducer-class to object map.
reduce
is an infix
convenience to enforce unified object type between our reducer and default state of its fractured state. This
is impossible to enforce using the Pair
class directly, so use reduce
instead of to
.
productReducer
looks like:
// has a different set of actions, and use top-level object we want to grab
val productReducer = typedReducer<Product, ProductActions> { state, action ->
when (action) {
is ProductActions.NameChange -> state.copy(name = action.name)
}
}
Now we can subscribe to the changes via:
store.select(fracturedSelector(productReducer))
.onEach { value ->
// do something with Product
}.launchIn(scope)
The fracturedReducer
will loop through each reducer to determine any state changes and update subscribers across the fractured state map.
Nesting fracturedReducer
is not supported, though compose
-ing is supported.
Typically to represent loading state you might create an object to represent success, error, loading, and result.
Kedux provides a convenience object KeduxLoader
to represent all actions, a reducer to handle state changes, and an effect to coordinate
the loading, success, and error states.
Also, KeduxLoader
supports clearing state via loader.clear
action type.
val userLoadingState = KeduxLoader<Int, User>("user") { id -> userService.getUser(id) }
// request action
store.dispatch(userLoadingState.request(5))
// resets state back to LoadingModel.empty()
store.dispatch(userLoadingState.clear)
// can manually call if you dont want the default effect
store.dispatch(userLoadingState.success(user))
store.dispatch(userLoadingState.error(error))
// you must use the LoadingModel object to represent it's state.
data class State(val user: LoadingModel<User> = LoadingModel.empty())
// define selectod
val userLoadingStateSelector = createSelector { state: State -> state.user }
// only emits if success is not null
val userSuccess = userLoadingStateSelector.success()
val userOptionalSuccess = userLoadingStateSelector.optionalSucces()
// only emits if error is not null
val userError = userLoadingStateSelector.error()
val userOptionalError = userLoadingStateSelector.optionalError()
// convenience extensions on selectors
store.select(userSuccess)
.onEach { success ->
// only returns if there's a success value
}
.launchIn(scope)
Since we want to avoid reflection, using the KeduxLoader
reducer requires a little more magic:
val reducer = anyReducer { state: State, action: Any ->
when(action) {
// catch all Loading action types here and modify state.
is LoadingAction<*, *> -> {
state.copy(
product = loader.reducer.reduce(state.product, action),
otherLoading = otherLoader.reducer.reduce(state.otherLoading, action),
)
}
}
}
We need to call the reducer manually in this case.