Scaleable FE with Redux and Elm architecture

August 05, 2016 0 Comments redux, elm

Scaleable FE with Redux and Elm architecture

Scaleable FE with Redux and Elm architecture

Inspired by http://staltz.com/unidirectional-user-interface-architectures.html and awesome Redux-Elm

Redux is a awesome state management framework, it's simple, powerful and easy to pickup. That's the main reason why we choice Redux over Flux or regular MVC at EgoPulse.

But the simplify comes with trade-off. Redux played pretty well at early state of our application, but when we start enjoying more complexity components, Redux shows its pain-point, lacking of the encapsulation.

Let take a look at Redux architecture

In Redux, every application state live in global state, which is immutable and can only access by View Provider with the magic "connect" HOC. When user trigger an interaction, for example, mouse click, the component will trigger an action to system by using a callback function passed by View Provider. This action will be pickup by Redux's store, go through all middleware to trigger side-effect or transforming and pass to the set of reducer in order to modify the global state.

Everything work great until our super-star developer get familiar with breaking down big component to multiple smaller ones, which makes me cannot happier. The deeper our component tree becomes, the more knowledge View Provider has to keep.

Let take a example, we have SearchBox component

SearchBox is a "dummy" component, it receive a "query" as input value, "result" as list of search result and two callback functions: onChange and onSearch. Whenever user modify the input or click to search button, those callback will be triggered

Now we want to create MovieSearchBox which uses SearchBox as composite component. MovieSearchBox will trigger normal search or quick search base on the length of query.

const search = (query) => {
if (query.length > 2) props.search(query)
else props.quickSearch(query)
}

And when we have everything we need, we create Movie as View Provider

// movie reducer
const defaultState = {
query: '',
result: []
}
const reducer = createReducer(defaultState, {
'Query.Changed': (state, payload) => {...state, query: payload.query},
'Search.Sucess': (state, payload) => {...state, result: payload.result},
'QuickSearch.Success': (state, payload) => {...state, result: payload.result}
})

Please ignore the fact that we need the magic connect as well as other configuration in order to make the above application work. As you can see, in Movie view provider, we need to pass not only "query" and "result" but also "onChange", "search" and "quickSearch" at parameters for MovieSearchBox if we want to use that component.

Everything still look nice, until the designer decided that you need a "Clear" button in order to allow user clear the query without using their "delete" key.

Okay, let implement the "Clear" button for SearchBox.

The implementation is pretty simple, you add another button, which will call "onClear" whenever user click on it.

But wait, you're not done yet, now you need to go back and update your MovieSearchBox

const search = (query) => {
if (query.length > 2) props.search(query)
else props.quickSearch(query)
}

Okay, you're half-way there, now update your Movie view provider if you want "Clear" button actually clear

const reducer = createReducer(defaultState, {
'Query.Changed': (state, payload) => {...state, query: payload.query},
'Search.Sucess': (state, payload) => {...state, result: payload.result},
'QuickSearch.Success': (state, payload) => {...state, result: payload.result},
'Clear': (state) => {...state, query: ''}
});
const onClear = () => ({type: 'Clear'})
//and of course, you have to pass onClear to your MovieSearchBox

Cool, now your "Clear" button ready to go. But wait a minute, I forgot to tell you, your teammates love your SearchBox, there use them for BookSearchBox, MediumSearchBox, etc. If you don't want your "Clear" button work in some places but not other places, you have to go and make changes in all components which are direct or in-direct use your SearchBox. Have a good day and I wish you won't miss any component.

But that's not the best part. One month later, a new member of your team working on a new screen which require a MovieSearchBox. He come to you to ask how he could add your MovieSearchBox to his screen. You said "You only need to import MovieSearchBox in your view, and wait, copy those line of code to your reducer as well"

// movie reducer
const defaultState = {
query: '',
result: []
}
const reducer = createReducer(defaultState, {
'Query.Changed': (state, payload) => {...state, query: payload.query},
'Search.Sucess': (state, payload) => {...state, result: payload.result},
'QuickSearch.Success': (state, payload) => {...state, result: payload.result}
})

"Okay, now you have the MovieSearchBox, simple right" and you're taking note in silence "Next time, if you add feature to SearchBox, remember his new screen".

You may in that situation before and we alway find a work-around for it. But I believe that in order to build a scalable application, we should resolve the problem of lacking encapsulation

Let see how Elm architecture can save the day

In case you not hear about Elm, Elm is a functional programming language, which's created to build scalable, complex front-end applications. I personally don't like anything other than javascript, but Elm architecture is something we totally can benefit from.

With Elm architecture, every component has two parts: view and updater. View will be a function, which contain the logic to return renderable definition of component ( ReactElement or HTML). Updater is a function, which contain all the logic relate to manipulate the model a.k.a state of component. Model can only be manipulate in updater by triggering an "action".
You could see the similarity between Elm and Redux architecture since Redux is inspired by Elm. The main different is Elm consider all component as UI program, which mean that they can be extract as stand-alone module at anytime. The application just a composition of all smaller UI program, a.k.a components. In order to do that, Elm introduce a concept call "mail box" which allow us to wrap the action, send it to the correct updater and the unwrap it.

Let try to re-implement our application by borrowing some idea from Elm architecture

We will have SearchBox

// updater
const init = (query) => {query: query || '', result: []};
const updater = new Updater(init())
.case('Query.Changed', (model, action) => {...model, query: action.query})
.case('Result.Changed' (model, action) => {...model, result: action.result})
.toReducer();

MovieSearchBox

// updater
import searchBoxUpdater, {init as searchBoxInit} from './searchbox/updater';
const init = (query) => {searchBox: searchBoxInit(query)}
const updater = new Updater(init())
.case('SearchBox', (model, action) => ({
...model,
searchBox: searchBoxUpdater(model.searchBox, action)
})
)
.case('SearchBox.Search' , (model, action) => {
const query = model.searchBox.query;
if (query > 2) search(query);
else quickSearch(query);
})
.toReducer();

And finally, the Movie

//updater
import movieSearchBoxUpdater, {init as movieSearchBoxInit} from './movieSearchBox/updater'
const init = () => {
movieSearchBox: movieSearchBoxInit()
}
const updater = new Updater(init())
.case('MovieSearchBox', (model, action) => ({
...model,
movieSearchBox: movieSearchBoxUpdater(model.movieSearchBox, action)
})
)
.toReducer();

Each component now will have one "view" and one "updater". View will contain the logic to render the component which receive a "model"- object contain all data it needs- and "dispatch"- a callback which help component dispatch an Action to system.

As you can see, now, we are able to move the "query" manipulate logic to out-side of Movie and put it into more suitable place - next to SearchBox. We also separate the "search" logic from Movie logic since Movie in our case don't care about how MovieSearchBox get the result. The Movie updater /reducer now only care about the logic of Movie's view which mean that it only has to do its job, no longer MovieSearchBox or SearchBox's job.

Now designer asked you to add the "Clear" button.

// updater

Now you have the clear button implementation. The next step will be ....

Nothing, you just need to sit back and enjoy your coffee, because of the encapsulation, changing SearchBox implementation doesn't require changes in its parent or super parent anymore. Unless the parent care about new changes

The best part is, know you are able to use SearchBox or MovieSearchBox and even Movie any where in your application without string attached (okay, you still need to now about the first level children's action in order to communication with them but you don't need to know your grandchildren and grand-grandchildren anymore)

Finally, your global state will look like this

You don't have to worry about the detail of movieSearchBox or searchBox but it alway under your control.

So, is it a best practice for large application using Redux ? It's a bit too soon to say anything but we are migration current application from pure Redux architecture to its architecture. So far we don't hit any rock and the migration is quite simple since we still base on Redux architecture. Of course it will take time for our developer to get used to new architecture which require them to think more when they write component. But I believe it's will be a good trip.

I will publish more articles about our journey at EgoPulse and of course, pro/cons of this architecture after our field tests. Please keep in touch if you're interesting and please give it or Redux-Elm a try. It's a awesome lib.

Scaleable FE with Redux and Elm architecture


Tag cloud