Skip to content

monstar-lab-oss/kedux

Repository files navigation

Kedux

badge badge badge badge badge

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.

Getting Started

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.

Swift Guide

Typescript Guide (TBD)

State

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)

Reducer

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)

Logging

Store.loggingEnabled: set this to log events to the native console as they come in. Preferrably this is set only on development builds.

Selectors

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.

Features

This library has a few features. TBD on full descriptions.

Store

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.

Supported Action Types

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())

Reducers

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

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 / Sagas

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()

Multiple Actions

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.

Advanced Features

Enhancers

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 States

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.

Loading States

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.