The delight of working on a React + Redux app
The React+Redux stack is probably the hottest topic currently trending in the frontend development scene.
Many things have been written either in favour or against it, and both sides do have valuable and undeniable points. As every other programming concept, it has its pros and its cons. The purpose of this article is not to list either of these.
The purpose of this article is just to note a few things that I, as a professional frontend engineer, really liked about it when I started using it on complex architectures. It is about things that left me in awe and made me say: “It was about time this thing got solved”. It is not an exhaustive list either. Just a reference to the ones that struck me the most.
Of course, I’m not saying that the architecture that this stack guides you to use does not impose some limitations or make a few things needlessly complex (like having controlled components in forms, or the fact that you have to be careful with your component’s update conditions in order to avoid performance issues) and in the end there is no “one framework suits all” rule.
However, it is all a matter of taste and personal choice and I feel really grateful to the guys that made and maintain it, as I find delight in using it! Anyway, without further ado, let’s move on to the key points.
A praise to unidirectional data flow
Redux architecture revolves around a strict unidirectional data flow [http://redux.js.org/docs/basics/DataFlow.html]. This means that all the data in your application follows the same data flow pattern, creating less cognitive fuzz and making it easier to predict the outcome of an action that may be added later in time.
There is not really a right or wrong way to design your application’s architecture; however, there are ways that help you build applications that are easier to understand, reason about, maintain and extend. And of course there are also ways that make you feel like the codebase of a certain complex feature is some kind of jungle with pathways blocked from aerial roots, spider webs and other kinds of booby traps. Ultimately making you hesitate to revisit the code a few months after implementation and curse the day a regression appears or a new feature is planned.
OK, I’m obviously going overboard and exaggerating, but thankfully redux stands proud in the former category, and even more awesomely, to my experience, it is the best pattern I’ve worked with, in order to handle complex user interfaces in a programmer-friendly way. (Exactly, we are always talking about making our apps user-friendly and get passionate about UX, but what about the codebase being programmer-friendly? Complex user interfaces are not bad, as a product scales it is natural and value adding to make it complex in a healthy way — it is the illogical use cases that are the ones toxic both for user experience and programmers’ peace of mind. But I’ll cover that in another post.)
Ok, I know redux was not the first to introduce unidirectional data flow, but I won’t start comparing it with flux, etc, either. I’m talking here about the React+Redux combination as a whole, which is also the one mostly supported by community at the time being.
When following a unidirectional data flow architecture, all the data in your app flow in one-only-direction (wow, really?). Let me explain the concept a bit; suppose that we have the following rather complex UI: Our screen consists of three lists of items, where only one of them is expanded by default and the other two start collapsed. Let’s call the one that starts expanded ‘active items list’, and the other two ‘draft items list’ and ‘archived items list’ (note to colleagues — any resemblance to actual circumstances is purely coincidental). And also let’s add the following functionality: when the last item from the ‘active items list’ gets deleted, the ‘archived items list’ should auto-expand. But when the ‘draft items list’ was already expanded before the deletion of the last ‘active’ item, there should happen no auto-expansion. Furthermore, if there are only ‘archived’ items to show and no ‘active’ or ‘draft’, the button that expands/collapses the ‘archived’ should be hidden and the respective list auto-expanded. But when the user unarchives one of them, the app should return to its initial state.
Are you still here? — Good!
Add some filtering options to these rules, to filter items by name and thus auto-expand etc depending on the current search results count, but also keep track of the total item count because if the ‘active’ list is empty due to filtering you’d like to just hide it, but if it is empty due to no items existing then it would be better to show a blank slate to the user.
When the data flow is bidirectional (or maybe omnidirectional?), you would normally set up a view Model
that listens on the three lists for events like addition, deletion and filtering of items and also set up the Controller
to listen on their respective views for expand/collapse actions, and then apply the aforementioned rules.
Easy peasy lemon squeezy, right? Think again! You would have to either pollute the view Model
with internal state information of the list views (collapsed/expanded, etc) or transfer the business logic to the Controller
and thus violate its purpose which is to solely pass data between the view and the model. Whichever approach you take, part of the business logic is implemented in the controller, and part is implemented in the model, which is also a bad thing.
Secondly, it is not far-fetched that e.g. the ‘archived’ items list view may trigger an event to fetch the list from the server when it is expanded, or perhaps handle some other business-logic related stuff. We would have to be careful when auto-expanding the list to not fire anything unwanted implicitly, or even worse fall into an infinite loop of fetching-expanding-fetching etc.
Apart from that, since we’re dealing with “listening” on the item collections for add/remove events, when an item changes state and goes from one list to another, we have to be careful to first trigger the addition event and then the deletion. If we fire the deletion first, our checks may find all the lists empty at the same time (corner case scenario, be we have to cater for those, too!) and thus render a specific blank slate view. Then, as soon as the addition event is fired, the view would be torn down and the previous lists would show up again, making for a weird user experience. Bonus stage: this corner case scenario needs to be documented somehow, because no one will remember it when the time comes to work on this code again.
Good luck, A. ensuring this code is bug-free, B. maintaining the code in case new features need to be added and C. letting a new colleague work on this code without first needing a notable amount of time to describe the architecture!
However, with Redux’s unidirectional data flow pattern, all the user actions and their effects follow this path:
- A user action on a
Component
(click etc)dispatch
es an action to the Reduxstore
- A
reducer
handles the action and takes care of transforming thestore
’s state accordingly, while keeping it the minimal representation of the total app’s state. - Through the use of
container components
the new values of our single source of truth (thestate
) are propagated to the sub-components asprops
.
Applying this architecture to our problem, every time the user performs an action that would affect the state of an item (switch between ‘active’/’archived’/’draft’) or the state of a list (expand/collapse), the reducer
would first update the various lists’ items and their states and then enforce all the various business rules based on the aforementioned states. After that, the state would propagate to the list components, informing them to show their expanded or collapsed state, hide or show a blank slate component instead, etc.
The key point here, that makes everything work smoothly despite the complicated business and view logic, is that all calculations are done in a centralized place, which has access to all the data that these calculations depend on. Also, we get to choose the order in which everything happens (first update all the affected items and lists, then enforce the view logic). Lastly, the new state is auto-propagated to all components that use it. We need not worry neither about having forgotten to fire the update or re-render method of a particular view, nor the possible side-effects that such a method may have as described earlier.
Essentially, we can say the following: With React+Redux, all the updates in state and components are action-centric. They take place responding to a specific action of the user. However, with the classic MV* patterns, the updates in models and views are effect-centric. Because the action fired does not get processed in a central place where all state information is available, they take place responding to the immediate effect of the user’s action, by listening for events. It’s clear that the first case is more explicit, more straightforward, and makes your code easier to remember and reason about.
All the rendering in a single place
Having worked with other frontend frameworks as well in the past, I know that one of the factors that induce cognitive load is when effects relative to each other happen from multiple and distinct places in the code. Take for instance the case where a particular span
in the UI needs to be updated at some point in the View
’s lifecycle with a new text. If we were using Backbone, we would have to introduce something like the following:
const myView = Backbone.View.extend({
template,
[…]
onRender() {
this.renderSpanText();
},
[…]
onAjaxSuccess() {
this.renderSpanText();
},
[…]
renderSpanText() {this.ui.span.text(this.textToRender);
}
});
Which means that A. while rendering of the most parts of the view happen automagically through the template, the specific span
element needs a separate method to render it explicitly and B. the renderSpanText
method would need to be called from various places within the view’s code, depending on the current state. This might not seem like a serious issue at first, although it is a sign of a code smell, but the given example consists of a really small view. Imagine how cluttered the code would be if we had a really complex view (whoops, no view nesting supported, either!). All the aforementioned issues arise from a single fact: the views can only be rendered as a whole, and not partially depending on what has changed since the last render. So, in order to avoid total re-renders and cause performance issues (and also unnecessary event handler unbinds and rebinds and other DOM reset/setup stuff) we would resort to code like in the previous example.
Enter React.js: With react’s reconciliation feature, you need not worry about the parts of your component that may re-render often. You would just declare the whole markup of your component in one place, and react will take care of the rest. It will update only the given span
as it updates (even adding attributes to it or changing its text on the fly, without detaching it from the DOM), and so you are free to continue coding happily and reserve your mental capacity for decisions that matter more, like the high level architecture of your app, etc.
Unit Testing, the right way
Unit Testing complex user-facing applications has always been a challenge. The essence of unit testing is to test every building block of your application independently, treating it as a black box. Unit tests should be agnostic regarding every unit’s internal workings and implementation details. It promotes building an application with modularized components that are loosely coupled. As a result, it can be performed better in such architectures.
However, complex user interfaces often have the trait that many different and seemingly independent to each other components, affect one another in explicit or implicit ways. Like updating a part of the screen when a user action happens in a completely different part (e.g. a user initiates a deposit so the view showing the balance should be instructed to update or, it’s implicit counterpart, the user creates an item within the app, a check is made comparing his/her current total items to his cap depending on his purchased subscription (add some business logic in the mix), and if he has reached it the app should automagically disable the buttons that create new content, listen for events of content deletion so that it enables them again and so on).
The frontend community uses various patterns to tackle such complexity, like pub/sub or mediator design patterns, but even this way it is not always an easy feat to ensure loose coupling between the various components of the user interface. With the react+redux combination, the unidirectional data flow and component nesting and reusability, loose coupling is easier to maintain both in the view part of the UI, as well as in the controllers and the action-handling functionality.
Since reducers are pure functions, you need only test that for every action they are supposed to handle, they cover all the possible use cases. (Black boxing — unit testing at it’s finest, promoting TDD as well!).
Since components are pure functions, they support nesting (in fact, you are encouraged to split them into subcomponents until they have only a singly responsibility), and react offers Shallow Rendering capabilities, unit testing is a piece of cake here, too. Ease of testing is enforced a lot when using Enzyme (and kudos to the awesome AirbnbEng team for creating), as it simplifies the process of shallow rendering each component and asserting that the props it passes to its children are the right ones depending on its own props. Should the component include some text or other HTML elements, you can perform assertions on these, too. Big win for unit testing, again!
This article turned out to be a lengthy one, I hope I have provided some new insights into the benefits of using react and redux together and why it makes your code better.
All the hype around it is really justified and things are only expected to get better. And that’s the beauty of the JavaScript scene. You have numerous options on frameworks, libraries and architecture patterns to follow and choose from (and surely there are equally awesome alternatives out there, too, I just haven’t had the opportunity to work on them on large scale projects yet). It might seem intimidating at first to try to catch up. But as you follow along, you find out that all this fuss helps beautiful code patterns and architectural designs emerge, or even get back in the spotlight (think about thunks).
If you would like to share your opinion, or even contribute a different point of view, feel free to comment!
Happy engineering!