The road to DRY control flow in large React Applications

MediaMarktSaturn Tech Blog
MediaMarktSaturn Tech Blog
7 min readSep 26, 2018

--

By Sebastian Bayerl

TL;DR

Business logic & control flow should be handled centrally in large React applications and thus be easily accessible and not redundant (Don’t Repeat Yourself). To reach this goal we tried two approaches and finally went with redux saga.

https://www.sitepoint.com/organize-large-react-application/

Building great Single Page Applications (SPA) in React and Typescript comes up with many questions like:

  • Boilerplate vs. from scratch
  • How to apply styling and theming
  • Consistent state handling

And finally, how to handle control flow shared by multiple components?

So what is meant by shared control flow handling? Let’s imagine the following online retail example:

A SPA contains a page holding a basket component which represents all the articles you might want to buy. These articles are represented as React components wherein you can change the article quantity or delete them. Another component contains a button which deletes the whole basket compilation.

Project Setup

To help you understand our journey better, we also provide a simple basket application example. For this example setup we will use create-react-app with typescript and create some components and views which represent the given example.

Our App.tsx will have two components:Header and BasketView

If we have a look into the BasketView, we can find three components: DeleteButton, BasketList, the BasketInfoModal plus an ArticleAdd, which adds mocked Articles. Our Props will be seperated into ConditionProps (props which specify certain terms within the component) and ActionProps (which represent the components events).

The example control flow

Taking a deeper look you may recognize that there are three main action points, which might result in an empty basket:

  • deleting the whole basket,
  • deleting the last article in the basket and
  • reducing the last article’s quantity to zero.

All these actions should trigger the same control flow:

When emptying the basket, a delete request to the backend will occur and a notification should open, which indicates the successful deletion of the basket. The current basket compilation should vanish.

Redux as the way to go

So how to keep this control flow as simple as possible and also include all the state handling of our components?

Changing state within a ui-application mainly results in changing the look and also behavior of ui-components. These sequence of changes represent the control flow, which also reflects the business process behind these consecutive steps.

As a result of the mesh of state and control flow we will work with a library that is predestined to handle state of large React applications: react-redux.

Container and Presenter

When using Redux, it makes sense to separate your components in presenter and container components. That means the presenter just cares about showing stuff and passes interactions with itself like clicking the delete button to another component via callback. The container then knows how to react on this and also supplies the presenter component with the correct data from the redux store.

Our BasketListPresentercould look like following:

First Stop: Views as “control-flow-master”

So where to put the control flow? When we first thought of handling control flow in our MediaMarktSaturn application, we wanted to keep the amount of dependencies low, to avoid updates and also reduce bundle size. That’s why we decided to stick with redux. So our views which represent enclosed business processes should handle all the control flow.

In our simple example, this results in following structure:

Our basket component and the button which deletes the basket are just presenter components that pass all interactions to the view, which eventually handles all these interactions in its container. This container is connected to the redux store and therefore it can trigger state changes after network requests.

Setting it up like that, we have a clear separation of concerns and UI changes will be handled at just one point. No redundancies and overlappings can happen within our current view.

As you can see, we’re handling all business logic in this view through following functions:

handleArticleDelete(...),
handleArticleQuantityChange(...),
handleBasketDelete(...)

With help of the mergeProps function we can connect the control flow with all events that trigger these flows. In our example we have reducers and actions defined in the following project structure:

store/
actions/
basket.actions.ts
reducers/
basket.reducer.ts

After successful basket deletion an action will be triggered, which is defined like following:

Finally our actions will be handled in the reducer:

But there are also downsides with this approach. Let’s have a deeper look at it.

https://thecodinglove.com/when-my-last-push-makes-everything-crash

For each upcoming feature that has to be implemented in a view, the container component of the view has to be adapted. When working in a bigger team, this can be prone to merge conflicts as the view container tends to get larger.

This also results in a growing view, where different control flow becomes hard to separate and eventually hard to understand for new team members.

Also the fact that we are not repeating control flow in a view means duplications still can happen between different views:

Say a new feature is required, e.g. the basket deletion should also be possible within the page header, we now have two locations, where we have to trigger the same flow.

By now, the single source of control flow is no longer be given, as pulling the logic up each time and pass it down to children is not a good practice.

Sagas, the destination

https://github.com/redux-saga/redux-saga

Considering all those constraints, let us rethink our former setup of handling control flow in view-containers. Within our frontend team, we started to evaluate different approaches and tools. The main goal again was to handle asynchronous control flow at a central point of the application, without repeating ourselves in different views.

This leads us to redux-saga. Sagas can help a lot in having just one single source of retrieving and manipulating data. With standard use of action dispatching, complex business logic can be triggered and then evaluated.

In our example the internal flow can be described like this: If we click on the delete button, an action will be dispatched. Instead of processing this action in a synchronous reducer we will catch it and evaluate it in sagas. Finally, we have one single implementation of business logic that is not tied to a specific view or component and can even be async.

So we are changing our current handling of state changes and control flow from this:

To our new setup with sagas:

Each saga now represents a piece of business logic defined once, loosely coupled to React components. This is how we can separate different features through different sagas. No more growing containers, but logically separated flows.

The new setup

Our basket view container just gets the minimal amount of data from redux store. Business logic is extracted from the container and moved into sagas.

As you see we also changed the responsibilities. No article quantity change method is listed in above code snippet. That means, our BasketList now concerns about Article State Changes, which results in following container component:

Our created sagas are placed here:

store/
sagas/
basket.saga.ts

Setting up sagas in your project isn’t the easiest thing if you are not familiar with generator functions before. But once you’ve learned saga specific functions like put or call, you become more and more familiar.

The implementation of the saga for deleting a basket, is similar to our handleBasketDelete from the beginning, although we use call for requesting the backend and put to dispatch further actions. Finally, we have to subscribe the saga to an action it should listen to and export all our sagas.

You may have recognized there is an additional action described above: DeleteBasketClickedAction

As sagas listen to actions, we need to add these actions to our ActionCreator and update the type of BasketActions.

In our store.ts we finally run all our sagas that have been implemented:

Conclusion

To manage state and control flow consistently it is makes perfectly sense to have a decoupling of logic from views/components in large React applications. We achieved this with redux saga and the concept of presenter and container components. Business is triggerd by simple actions, which are processed by sagas and eventually change the redux store through reducers.

Handling control flow in views may make sense depending on the app you are developing but can become complicated to work on within bigger product teams and larger applications.

You can have a look at the full project at https://github.com/bayerlse/saga-demo.

--

--