paint-brush
Lessons Learned Implementing Redux on Androidby@nishtahir
11,708 reads
11,708 reads

Lessons Learned Implementing Redux on Android

by Nish TahirFebruary 3rd, 2018
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

When a lot of people think of the <a href="https://hackernoon.com/tagged/redux" target="_blank">Redux</a> architecture they think of the web. This isn’t really surprising because it originated and gained a lot of popularity there. At its core, it’s a simple application architecture that describes a system of organizing and manipulating state. This means that it can be applied to any kind of application development including mobile.

People Mentioned

Mention Thumbnail

Coin Mentioned

Mention Thumbnail
featured image - Lessons Learned Implementing Redux on Android
Nish Tahir HackerNoon profile picture

When a lot of people think of the Redux architecture they think of the web. This isn’t really surprising because it originated and gained a lot of popularity there. At its core, it’s a simple application architecture that describes a system of organizing and manipulating state. This means that it can be applied to any kind of application development including mobile.


For those who are unfamiliar, the Redux architecture centers around a strict unidirectional data flow. All data within the application flow in one direction through components. At a high level, Redux aims to ensure deterministic view renders as well as deterministic state transformation and reproduction. Determinism here refers to the ability to know that at any given point in time, your application state is always valid and can be transformed into another predictable and valid state. Your UI components then update based on the given state.

The Redux architecture centers around three main components:

1. Store

This is simply a state container that holds on to your app state. It holds an immutable reference representing the entire state of your application and can only be updated by dispatching an Action to it.

2. Actions

Actions contain information to be sent to the store. They represent how we want our state to be changed. For example, consider the following action:

data class AddTodoAction(val text: String)

It would be dispatched to a given store to update the application state:

store.dispatch(AddTodoAction("Write blog post"))

3. Reducers

Since neither Actions nor the Store update state themselves, we need a component dedicated to doing this. That’s where Reducers come in. They simply take an Action and State and emit a new State.

fun reduce(oldState: AppState, action: Action) : AppState {    return when (action) {        is AddToDoAction -> {            oldState.copy(todo = ...)        }        else -> oldState    }}

Now that we understand the components, let’s see how they fit together. The Redux flow is very simple. Your app renders the content of your app state on your view layer. User interactions dispatch actions which are forwarded to Reducers which operate on — and emit — a new app State.

I recently went about implementing the Redux architecture on a fairly large project, so I wanted to give a perspective on some lessons that I’ve learned along the way.

1. Do not have multiple stores in your app

Having more than one store in your app may seem like a great idea at first especially if your goal is a separation of concerns. However, each store together with its data flow can be thought of a closed loop system, making it difficult to synchronize state between them. Attempting to do this often requires you to attempt to dispatch state changes in response to other state changes which can result in an infinite loop.

mainStore.dispatch(FetchGridItemsAction())...

override fun onNewState(newState: MainState) {    // FIXME: You should never dispatch an event in response to a state change.    // This can potentially create an infinite loop.    homeStore.dispatch(UpdateHomeGridAction(newState.gridState));    ...}

Having multiple stores in this manner makes your architecture very rigid and difficult to change in the long term. This is especially problematic in an environment where requirements can change at any moment.

A better approach to take is to maintain a single global app state that contains multiple sub-states.

data class AppState(val LoginState,                    val HomeScreenState,                    val GridState )

2. Keep your app state as flat as possible

The deeply nested state results in a lot of boilerplate code and is difficult to update since everything is immutable and requires you to create a new app state for every state update. While it may seem intuitive to have your state data models match the hierarchy of your UI, it makes updating deeply nested portions of your state very difficult. For example, considering the following data model classes:

data class State(val sections: List<Section>)

open class Section(val articles: List<Article>)class Home(articles: List<Article>) : Section(articles)class Discover(articles: List<Article>) : Section(articles)

class Article

Instantiating and updating the state object would look something like this:

val state = State(sections = listOf(                  Home(listOf(article1, article2)),                  Discover(listOf(article1, article2))))

Updating deeply nested properties such as articles here ends up being very tedious even using Kotlin’s excellent copy mechanism:

val newHome = Home(listOf(newArticle, state.sections[0].articles[1]))state.copy(sections = listOf(newHome, state.sections[1]))

The solution here is to avoid nesting as much as possible. This is especially true with a state object that contains collections.

Your app state does not need to match the hierarchy of your UI.

val state = State(sections = listOf(                 Home(refs=listOf(0, 1),                 Discover(refs=listOf(0, 1))),                 articles = listOf(article1, article2))

3. You can have a healthy mix of action creators and middlewares

Action creators are basically factory classes for actions. They allow you encapsulate things that you may want to do before dispatching actions such as making network requests or accessing shared preferences etc… and return an action as a result of completing the task:

class TodoActionCreator {    fun createAddTodoAction(content: String): Action {        ... // do stuff        return AddTodoAction(transformedContent)    }}

Middlewares, on the other hand, are a lot like Reducers except they do not create new app state. Instead, they perform tasks and choose to forward along actions to the Reducers, discard them, or dispatch new actions altogether. A great use of middleware is for things like logging actions as they are processed during the flow:

class LoggerMiddleware {    fun interact(store: Store, action Action) {        logDebug { action.toString() }    }}

Middlewares work best in situations where you want to do something globally across the entire application. Things like logging, Analytics and persistence make great candidates for middleware. However, actions that tend to be specific to the use case such as API calls should generally be delegated to action creators.

4. Reducers should be pure functions

The Redux architecture encourages a functional approach where you compose pure functions. Pure functions are basic functions that are deterministic in nature. Meaning that the output of a given function would always yield the same result if called with the same inputs. This is because pure functions have no internal state and leave no side effects.

Reducers should embody this philosophy. They should always take a state and an action and return a new state.

class Reducer {    fun reduce(state: State, action: Action) : State {        ...    }}

Any additional information that is required by the reducer to reduce the state correctly should be passed in the action and the reducer should respect state immutability and always return a new state.

If you require a side-effect in response to an action, consider using a Middleware instead.

5. Unit tests save lives and hair

Unit tests are where the Redux architecture truly shines in my opinion. Since reducers are pure functions with no internal state, they are deterministic in nature. They will always return the same output state for a given input state and action. This makes them dead simple to test.

Additionally, given that state and actions are light-weight data objects, you don’t require any mocks for tests. Simply construct instances of your state and actions. Testing a reducer looks like this:

val reducer = MyReducer()val state = MyState(...)val action = MyAction(...)

val newState = reducer.reduce(state, action)

assert(newState …)

If there are instances where you require data that are typically acquired from various places such as an external API or datastore. You can keep your reducer tests simple by making a “dumb” constructor for your action as well as a smart constructor/factory that supplies the data you need.

class MyAction(val data1: String, val data2: String) {

  companion object {    fun create(apiResponse: Response, datastore: Datastore): MyAction {      val data1 = apiResponse…      val data2 = datastore…      return MyAction(data1, data2)    }  }

}

And as a result, your reducer remains crazy easy to test.

val action = MyAction(data1 = "data1", data2 = "data2)val newState = reducer.reduce(state, action)

And you can test the factory separately where you may want to use mocks.

val response = mock<Response>()val datastore = mock<Datastore>()...val action = MyAction.create(response, datastore)

assert(action.data1 …)

6. Just use Kotlin

The Redux architecture certainly has a lot of ceremony around structuring components. While these contribute to keeping things clear and consistent as the project grows, your choice of language may determine the extent to which you are able to manage the boilerplate.

Features such as data classes, when statements, multiple top-level classes, and higher-order functions make a huge difference in code clarity. For example when trying to match actions in reducers one may choose to use instanceof checks;

if (action instanceof AddTodoAction) {    return reduceAddTodoAction(oldState, action);} else if (action instanceof RemoveTodoAction) {    return reduceRemoveTodoAction(oldState, action);} else if (...) {    ...}return oldState;

This can get very unwieldy very quickly. Another option would be to use string constants as actions along with string matching in a switch but that isn’t a whole lot better. What does present a decent solution is the Kotlin when statement.

return when (action) {    is AddTodoAction -> reduceAddTodoAction(oldState, action)    is RemoveTodoAction -> reduceRemoveTodoAction(oldState, action)    else -> oldState}

Conclusion

While Redux may have its origins on the web, it has a lot of really good ideas behind the architecture that we can learn from and bring to Android. While our platform, languages, and tools may be different, we share a lot of the same fundamental problems such as striving for complete separation of concerns between our views and business logic.

Redux is by no means a perfect silver bullet but at the end of the day; no architecture really is. It’s still relatively new but shows a lot of promise and we’re excited to see it mature on Android. If you are interested in playing around with Redux, I recommend taking a look at Evan Tatarka’s Redux library or ReKotlin which is a port of ReSwift.

Originally published on the Pusher blog.