Combining Business Logic and State Management in React
State management is a hot topic in react. Lots of libraries like Redux, Mobx, Recoil etc. solve the problem of making global state accessible inside React components. The core point is to avoid re-renders triggered by state changes for components that use parts of the global state which where not changed.
Redux adds three main concepts to solve this problem: Reducers, Actions and Selectors. Actions describe state transactions and can be called from components. Reducers contain the actual logic and transfer the computed results from actions into global state. Furthermore, Redux provides functions to extract partial global state to components, called selectors.
This sounds pretty nice, right? Looking at the problem, Redux provides a well tested and documented solution. So I could finish this article here. Done.
Using Redux, I struggled at some points, which lead to bad developer experience for me (this may not be a fault on Redux side, but shows only my experience).
1) Separation of actions and reducers: Basically, business logic should be placed inside reducers. Reducers get actions containing payload and a description. After evaluating which action was dispatched, the reducer performs its logic and returns a result containing partial state that is applied to Redux’ internal state. For me, it would be great to write one function to perform a particular task and then merge the returned value into global state automatically.
2) Call of actions: In Redux, actions need to be dispatched, which means calling a dispatch function and provide some input. It’s not the default case to have a chance to catch errors in reducers within the calling function or wait until actions are completed.
4) Type-safety: This is my main issue I struggled with: I wanted to dispatch actions in a manner that its type-safe by simply calling predefined functions. Although there is a section covering typescript usage for Redux, it does not that well fit to my thoughts about how things should work .Getting things type-safe that depend on types that are not known on build time is quite difficult. Consequently, matching all needs of every developer to achieve this is not possible. This is why I wanted to try it myself to get my state management type-safe.
Although Redux provides solutions for these things, I didn’t want to use a framework which basic concepts do not match my needs to code in react. Furthermore, there are a lot of alternatives that may be used, but I wanted to have something that really covers my needs. Consequently, I decided to create a lightweight alternative from scratch. The complete source can be found here.
One Note here: Of course the state management here does not aim to be a competitor to Redux or the alternatives presented before, it only covers a small fraction of Redux’ feature set and should point out some ideas how to implement global state management and how to write testable business logic in react.
Concepts
I reuse the existing concepts of reducers, actions and selectors from Redux. However, they are used a bit different in this approach.
Global State: Similar to other state management libraries (in Redux its called a store) this represents state that must be shared inside the application. Using the modules, this state can be separated into different logical states, similar to having multiple stores. However, in the end a single global state is used.
Modules: I define business logic as a set of modules. Each module has its own state and a set of actions, that potentially use the current state and input to change fields in the state. There are many articles covering how to extract such logics from components, this approach aims to do exactly that. Although the modules come with their own, dedicated state, all module states are merged together in one global state, to have a single React context provider instead of multiple. Using selectors, the components can create views on their needed partial state.
Actions: Instead of using a dispatch function to perform an action, an action in this approach is a (potential async) function that gets an optional GetStateFunc parameter als last argument. The previous arguments are free to choose for each action. The return value of an action must be partial type of the global state. An action can be called directly from React components without the need of using any middleware to dispatch them. The GetStateFunc should be ignored for calling the action. Furthermore, errors can be catched using try-catch or promise.catch and the result can be awaited to be used immediatly, without waiting for state changes. Basically, an action combines the Redux action and reducer flow into one function.
Reducers: A reducer in this view also combines new (partial) state with the current global state. In this approach, there is no need to implement own reducers. This library provides a generic reducer that accepts output from actions and merges it automatically into the global state.
Selectors: A selector is used to extract partial state out of the global state. The main use-case for this is to avoid re-renders for components if fields in the global state change that are not used in them.
Implementation
Appstate
This approach comes with a separate registration of each module, consisting of the state and the actions that need to be combined into global state. This may look like a bit overhead, but allows smooth type safety in the end. However, the module registration could be automated easily. The registration is shown in the next code sample:
As can be seen, the modules of the app are imported into the appstate. After merging the type definition of all imported actions and states, the global appstate will be exported to be used in the global state provider.
Global State provider
The global state itself is implemented via a React context. This state contains the appstate and actions each registered module. This provider also contains logic to register multiple selectors, so that each selector call in React components will return the correct partial state and will cause re-rendering the respective component. This is inspired by use-context-selector.
Modules
As mentioned before, a module represents logical related code. The next code snippet shows a sample user module, that contains a login and logout action. Its initialized with a default state defined by an interface. The first action is login. It gets some sample arguments, username, password and keepMeLoggedIn. The last function, _getState, can be used to retrieve partial state. Login returns a currentUser object, that will be merged into global state after the function returns. This is done by the generic reducer.
Reducer
This approach generally uses two reducers. At first, a generic reducer is used to create type safety state objects. Its used in a useReducer hook that returns a dispatch function. This function merges the input into the global state and is used in the second reducer, which core responsibility is to wrap each registered action. The applyReducer function forwards all passed args to the action and adds a function that returns the current global state. The result of the action is awaited and then returned, so the caller can directly use the result for further processing.
Usage
The following code snippet shows how to use this global state management approach. The useActions hook returns all registered actions as typesafe functions. As can be seen in loginImpl, the action can be awaited. Internally, login changes the global state and potentially triggers re-renders. The useAppState hook gets a selector as first argument that returns partial state observed from the global state. Each useAppState call is registered in the global state provider and will only trigger re-renders, if one or more of the returned fields change.
Summary
As you may have noticed, this approach comes with some typescript overhead, especially the need of registering all modules in the appstate. However, as trade-off one can reach type safety and lightweight global state management and a basis to split business logic into modules, resulting in clean separation of concerns and easy testability. Of course, this is not as complete as the mentioned alternatives above, but it may be a good starting point for a next, more complex React project or to play around with state management, reducers and selectors. Again, the link to the full code repository.
Future Work
The next planned steps to bring this further are improved (or at least automated) module registration to ensure type safety and to create a full library, so that the state management can be easily imported into new or existing projects.
The next planned article is about plugin management in React. This will contain plugin architecture, loading and some sample plugins. Looking forward tot this!