How To Implement Complex Business Logic in A Predictable Manner
Starting a new project will always get you thinking about the choices you have made in the past. Seeing how React is highly flexible, you have a multitude of options for every layer or concern that may be needed for your UI architecture. The most important choice, however, would be how to manage the state of your application.
My goal is to help you make that decision. In the following paragraphs I’ll take a look at Redux as a state management solution and guide you through the reasoning that made me embrace this particular tool for me and my team. I’ll share some real life scenarios that reinforced my decision, and hopefully after reading this material you’ll agree with me in saying that the key to a well-designed application consists of separating UI state from the behavior demanded by business logic.
State in React: useReducer vs. Redux
While a lot of people might be tempted to go for React’s new features and use hooks – as they are a good option for prototyping – they don’t seem to be enough for a real-world application. If you keep adding features to your prototype while simultaneously trying to maintain the simplicity of your architecture, it will simply not scale well.
You will end up using these hooks in wrong ways as you progress. Routing, data, fetching, internalization, forms, and even CSS will start to intertwine, and handling these interdependencies will eventually scramble your code into an irredeemable mess. So it’s safe to say that your interaction, as a developer, with the rest of the layers will suffer if you don’t have a clearly defined set of rules to turn to when constructing your architecture.
UseReducer: Why You May Think It Helps
Now, for context – React evolved a lot along the way. From a simple UI library with a singular mission – rendering the DOM -, it grew to become a runtime that embodies numerous elements pertaining to UI logic, such as managing the application state and handling fragments of your application load.
One way React can manage state is through Flux, a simple architecture that revolves around the state and the actions that can be dispatched to update it. Using Flux has been made easier by the useReducer hook.
Some of us were tempted to use these hooks to manage application state, and soon we started wondering about every single bump in the road. There are many questions around what can be considered a slice of the state, and how one can share it between components without elevating it to the application root component. While one can find answers on the internet, there are as many opinions as there are users. Ultimately, the decision on how to move forward belongs to the developer.
useReducer was created to accommodate the shortcomings of the useState hook. It has been optimized inasmuch as it can now handle complex data structures (not just primitives and lists) and batch updates. However, its applicability is still very narrow: from UI library components (dropdowns and inputs, for example) to low level components that can remain uncontrolled, useReducer should be in charge of state that does not influence parent or sibling components.
Redux, Better for Complexity
On the other hand, Redux can be used to manage interaction and communication between multiple components. It has recently released a best practices guide in which it provides mature, comprehensive answers to developers looking to manage complexity in React applications.
Now, don’t get me wrong – I am not telling you to specifically use Redux. What I am suggesting is for you to use the right tool for the job you have at hand. There are plenty of alternatives: MobX, overmind, or recoil (check this list for even more options). Personally and on my own, I would have gone with overmind. However, while it has a great deal of awesome ideas (effects, immutable data, state-machines), it would have made it harder for my team to catch up on basics.
So we chose Redux. Like the rest of the tools above, it has been adopted by the community because of the experience it offers to developers. While it does bring some boilerplate code to projects, the finished application will be debuggable and predictable.
How to Leverage Redux in React Applications
Even if the Redux team did a fantastic job documenting best practices on how to properly use the library, a few puzzling questions still linger.
One of the layers you need to worry about is the routing. react-router is the go-to choice of the community. Mixing Redux with React-Router is easier said than done, though. In my experience, managing side effects from visiting a route, or transitioning from one route to another, following a successful form submission, will make you regret choosing the easy way out.
Luckily, there are libraries like connected-react-router that can keep things in check. This one, for instance, forces you to create and send a history instance to the ConnectedRouter. This feature facilitates a programmatic way to move the user from one route to another. It also allows the developer to listen in on the current route and “react” when it changes.
Why Complicate Things?
Well, oddly enough, we need to be thorough in order to keep things simple on the view layer. All of the complexity should be placed into the background. As a developer, I shouldn’t have to spend time investigating where redirects happen in my page components.
For example, let’s take the instance in which a user ends up on a listing page after successfully submitting a form. If I were to use hooks, I would also have to turn to useHistory to push the listing route when calling the onSubmit property. Three problems arise:
- The developer would be forced to return the post/put promise in the onSubmit action so that s/he can wait for it it and subsequently redirect.
- The component responsibility is heightened. In this instance, a second property (redirectOnSuccess) might be needed for control purposes, should this redirect even happen. As a bonus, you get a mind teaser: What would be the proper default value for this property?
- The redirect URL is embedded in the component, which prevents the developer from reusing it. Another property would be needed – redirectUrl.
You may be thinking I’m playing the devil’s advocate, but these are real-life scenarios that I myself have encountered! A lot of developers find it easy to keep adding properties to a component, or to adjust them to new requirements which are not even that complex. This is very similar to overloading a function with extra arguments in order to control how it behaves, which basically turns it into an architectural nightmare.
Keeping Layers Separate
This kind of UI logic can be controlled better if removed from the view layer. Such a change would also bring about other great benefits, such as having a simpler architecture, with React in charge of the view layer and nothing else. This would mean that the components should be easier to test. Moving this logic at Redux’s action level means that the UI logic will interact in a single instance with the UI logic. The store can be unit tested independently so that you can ensure that the business logic is flawless. Checking how the data transitions from stores to components and view segments can be done by simply writing some integration tests.
This also means that you can obtain a centralized state which is responsible for the UI logic.
State Management Pro Tip: Don’t Put All of Your Eggs in the Same Redux Basket
Redux is a great way to manage complexity, but it has its flaws. Complex flows are hard to follow, and testing for side effects can be quite troublesome. That’s why it’s imperative to separate UI flows from business logic.
Some great options in this sense:
- split the action in atomic operations. This makes it easier to test and reuse these “operations” in other actions.
- externalize the logic into a module and communicate the result to the redux layer.
Both of these solutions will transform the state management solution into an overrated setter that would just reinforce a “payload” in the state, without applying any other logic.
The tradeoff here is to have testable modules of business logic that report back to your Redux store. This way your application maintains the previously mentioned predictable and debuggable status.
Modules – They Keep Your UI Architecture Simple
Deciding what should be “modularized” is a bit tricky, as it depends on the overall architecture. My rule of thumb here is to have the business rules separated from the view / UI logic. An example would be an authentication service – its responsibilities are clear: maintain an active session as long as the user is interacting with the page.
We have no restrictions regarding the patterns that can be applied, provided that they respect the rules of the overall architecture, such as maintaining a clear separation of control, reporting back to the view layer (only via the Redux store), and keeping the footprint of the API as small as possible. You can use a class, a pub-sub pattern, or whatever feels like a good fit for the feature you have at hand.
In order for the new module to accommodate these requirements, it would need to communicate with different services, such as the history and the store.
Real Life Example: Authentication Module
Now the UI view can login a user by using the Redux layer. There are two options here, in order to have the actions access this newly created service: you either import the service in the actions file, or you expose it via the redux-thunk’s 3rd argument. In our case, we used the import approach in order to keep better track of what we use, according to context.
Either way, the important takeaway here is to avoid calling services directly in the view. The Redux layer is a much better fit since it can manage both the flow itself and its side-effects.
This respects the simple rules we set for this architecture, as we separated UI logic (loading state, handle invalid data) from the actual business logic the service is responsible for. The API it exposes is minimal, which means that the separation of concerns is properly done and easily testable. The output that concerns the UI layer is reported back to the Redux layer and keeps its logic separated from the view, which means that you can get away just with unit tests for the interface this module exposes, without a jsdom or enzyme environment.
It is down to the Redux layer to convey the information outputted by this service. It may not even react to it. If Redux “chooses” to react to the “AUTH_TOKEN” action, then the views interested in this specific slice of information would be notified. But the store might receive an update from the USER_INACTIVE action to display a message in order to let the user understand why he is now visualizing the login screen. This is a different concern that would be tested as part of the reducer suite.
State Management in React: Key Takeaways
Managing complexity in your application is not an easy task. Using a tool that helps developers and eases their work is essential, particularly when working within a large team.
useState and useReducer would not make the cut from a side effects standpoint, as they couple specific views with specific behaviors that will be hard to control and customize in the future. This is one of the many reasons why we chose Redux.
Controlling when the data should start loading can be made easier by the connected-react-router, by listening to the history changes, and by dispatching Redux actions that allow you to control when said data is being fetched. It follows that you can avoid misusing useEffect to trigger data fetching. Also, you can redirect users programmatically when the UI logic demands it.
More often than not, managing application complexity can seem overwhelming. But it shouldn’t. Instead of inserting all business logic in Redux, it’s easier to create modules which allow you to draw a line between UI state and the desired behavior or functionality. This frees you from the Redux pattern and allows you to move beyond what is considered application state, in a realm that accommodates the implementation of complex patterns: repetitive tasks than can be interrupted when a certain condition is met, services that handle single responsibilities, and classes that can store references to non-serializable values, like promises or instances of other classes.
While giving you enough flexibility as to model the code using the pattern that is best suited for a specific task, common sense states that it is up to the developer to notify the view layer of any needed changes, via the unidirectional data flow provided by Redux. Ultimately, combining these modules with different services while maintaining a centralized state for the view layer allows the application to remain predictable and debuggable.
Codrin is a senior frontend engineer at Bytex Technologies, and currently the frontend technical lead at Kubernetes management platform Spectro Cloud. He’s on a mission to save the web with flawless code, and in his spare time he tries to enjoy movies – a hard task, given his particularly high standards.