Experience of using redux without reducers
I would like to share my experience of using redux in an enterprise application. Speaking about corporate software within the article, I focus on the following features:
- First, it is the amount of functionality. These are systems that have been developed for many years, continuing to grow new modules, or to infinity complicating what is already there.
- Secondly, often, if we consider not a presentation screen, but someone's workplace, then a huge number of linked components can be mounted on one page.
- Thirdly, the complexity of business logic. If we want to get a responsive and pleasant-to-use application, much of the logic will have to be made client-side.
The first two points impose limitations on the performance margin. More on this later. And now, I propose to discuss the problems that you encounter, using the classic redux - workflow, developing something that is more complicated than the TODO - list.
Classic redux
For example, consider the following application: The
user drives a poem - gets an assessment of his talent. A control with a verse entry is controlled and a recalculation of the score occurs for each change. There is also a button that resets the text with the result, and a message is shown to the user that he can start from the beginning. Source code in this thread .
Code organization:
There are two modules. More precisely, one direct module is poemScoring. And the root of the application with functions common to the whole system is app. There we have information about the user, display messages to the user. Each module has its own reducers, actions, controls, etc. As the application grows, new modules multiply.
The cascade of reduers using the redux-immutable form the following fully immutable state:
How it works:
1. Control the action-creator dispatch:
import at from'../constants/actionTypes';
exportfunctionpoemTextChange(text) {
returnfunction (dispatch, getstate) {
dispatch({
type: at.POEM_TYPE,
payload: text
});
};
}
The action type constants are moved to a separate file. First, we are so safe from typos. Secondly, we will be available intellisense.
2. Then it comes to the reducer.
import logic from'../logic/poem';
exportdefaultfunctionpoemScoringReducer(state = Immutable.Map(), action) {
switch (action.type) {
case at.POEM_TYPE:
return logic.onType(state, action.payload);
default:
return state;
}
}
The processing of logic is moved to a separate case function . Otherwise, the reducer code will quickly become unreadable.
3. Press processing logic, using lexical analysis and artificial intelligence:
exportdefault {
onType(state, text) {
return state
.set('poemText', text)
.set('score', this.calcScore(text));
},
calcScore(text) {
const score = Math.floor(text.length / 10);
return score > 5 ? 5 : score;
}
};
In the case of the “New poem” button, we have the following action-creator:
exportfunctionnewPoem() {
returnfunction (dispatch, getstate) {
dispatch({
type: at.POEM_TYPE,
payload: ''
});
dispatch({
type: appAt.SHOW_MESSAGE,
payload: 'You can begin a new poem now!'
});
};
}
First, dispute the same action, which resets our text and evaluation. Then, we send an action that will be caught by another reducer and will display a message to the user.
Everything is beautiful. Let's create our own problems:
Problems:
We posted our app. But our users, having seen that they were asked to write poems, naturally began to post their work, which is incompatible with the corporate standards of poetic language. In other words, we need to moderate obscene words.
What do we do:
- in the entered text it is necessary to replace all uncultured words with * censored *
- In addition, if the user hammered a dirty word, you need to warn him with a message that he is doing wrong.
Good. We just need, in the course of text analysis, in addition to calculating the assessment, to replace bad words. No problem. And yet, for the message to the user, you will need a list of what we deleted. The source code is here .
We are remaking the logic function so that it, in addition to the new state, returns the information necessary for the message to the user (replaced words):
exportdefault {
onType(state, text) {
const { reductedText, censoredWords } = this.redactText(text);
const newState = state
.set('poemText', reductedText)
.set('score', this.calcScore(reductedText));
return {
newState,
censoredWords
};
},
calcScore(text) {
const score = Math.floor(text.length / 10);
return score > 5 ? 5 : score;
},
redactText(text) {
const result = { reductedText:text };
const censoredWords = [];
obscenseWords.forEach((badWord) => {
if (result.reductedText.indexOf(badWord) >= 0) {
result.reductedText = result.reductedText.replace(badWord, '*censored*');
censoredWords.push(badWord);
}
});
if (censoredWords.length > 0) {
result.censoredWords = censoredWords.join(' ,');
}
return result;
}
};
Let's apply it now. But how? In the reducer we no longer have to call it, since we will put the text and the assessment in the state, but what to do with the message? To send a message, we, in any case, have to dispute the appropriate action. So we are finalizing the action-creator.
exportfunctionpoemTextChange(text) {
returnfunction (dispatch, getState) {
const globalState = getState();
const scoringStateOld = globalState.get('poemScoring'); // Получаем из глобального стейта нужный нам участокconst { newState, censoredWords } = logic.onType(scoringStateOld, text);
dispatch({ // отправляем в редьюсер на установку обновленного стейта
type: at.POEM_TYPE,
payload: newState
});
if (censoredWords) { // Если были цензурированы слова, то показываем сообщениеconst userName = globalState.getIn(['app', 'account', 'name']);
const message = `${userName}, avoid of using word ${censoredWords}, please!`;
dispatch({
type: appAt.SHOW_MESSAGE,
payload: message
});
}
};
}
It is also required to modify the reducer, since it no longer calls the logic function:
switch (action.type) {
case at.POEM_TYPE:
return action.payload;
default:
return state;
What happened:
And now, the question arises. Why do we need a reducer, which, for the most part of the actions, will simply return payload instead of a new state? When other actions appear that process the logic in the action, will it be necessary to register the new type action-type? Or maybe create one common SET_STATE? Probably not, because then, there will be a mess in the inspector. Means we will produce the same case?
The essence of the problem is as follows. If the processing of logic involves working with a piece of state, for which several reducers are responsible, then you have to write all sorts of perversions. For example, the intermediate results of case functions, which are then necessary with the help of several actions, should be scattered across different reducers.
A similar situation, if the case function needs more information than is available in your reducer, you have to take out its challenge in an action where there is access to global steel content, and then send a new state as payload. And it will be necessary to split redusers in any case, if there is a lot of logic in the module. And it creates great inconvenience.
Let's look at the situation from one side. We in our action get a piece of state from the global. This is necessary to conduct its mutation ( globalState.get ('poemScoring');). It turns out, we already know in the action, with which piece of the state we are working. We have a new piece of state. We know where to put it. But instead of putting it in the global one, we run it with some text constant across the cascade of reducers, so that it passes through each switch-case, and substitutes only once. From the realization of this, roar me. I understand that this is done for ease of development and reduced connectivity. But in our case, it no longer has a role.
Now, I will list all the points that I don’t like in the current implementation, if I’ll have to scale it in breadth and deeper for an unlimited time :
- Significant inconvenience when working with a state outside of the reducer.
- The problem of code separation. Every time we dispute an action, it passes through each reducer, passes through each case. This is convenient, do not bother when you have a small application. But, if you have a monster that has been built for several years with dozens of reducers and hundreds of cases, then I begin to think about the expediency of such an approach. Perhaps, even with thousands of cases, it can not have a significant impact on speed. But, understanding that when typing a text, each press will cause a passage through hundreds of cases, I cannot leave it as it is. Any, the smallest lag multiplied by infinity tends to infinity. In other words, if you do not think about such things, sooner or later, problems will appear.
What are the options?
a.Isolated applications with their own providers . It is necessary to duplicate the common parts of the state (account, messages, etc.) in each module (sub-application).
b. Use plug-in asynchronous reducers . This is not recommended by Dan himself.
c. Use action filters in the reducer. That is, each dispatch is accompanied by information on which module it is sent to. And in the root modules reducer, set the appropriate conditions. I tried. Such a quantity of involuntary errors was not before or after. Constant confusion with where the action is sent. - Every time when the action dispute occurs, not only the run on each reducer takes place, but also the return state is collected. It doesn’t matter whether the condition in the reducer has changed - it will be replaced in combineReducers.
- Each dispatch causes the mapStateToProps to be processed for each associated component that is mounted on the page. If we split up reductors, we have to split up disputes. Is it critical that we use the button to wipe the text and display a message using different dispatch? Probably not. But I have optimization experience when reducing the number of disputes from 15 to 3 allowed us to significantly increase the responsiveness of the system, while the amount of business logic that was processed was unchanged. I know that there are libraries that can merge several disputes into one batch, but this is a struggle with the investigation with the help of crutches.
- When crushing disputes, it is sometimes very difficult to see what happens after all. No one place, everything is scattered across different files. It is necessary to search where the processing is implemented through the search for constants for all sources.
- In the above code, components and actions refer directly to the global state:
const userName = globalState.getIn(['app', 'account', 'name']); … const text = state.getIn(['poemScoring', 'poemText']);
This is not good for several reasons:
a. Modules should ideally be isolated. They do not need to know where in the state they live.
b. Mentioning the same paths in different places is often fraught with not only errors / misprints, but it also makes it extremely difficult to refactor if the global state changes or changes its storage method. - Increasingly, while writing a new action, I had the impression that I was writing code for the sake of code. Suppose we want to add a box to the check page and reflect its boolean state in the stack. If we want a uniform organization of actions / reducers, we will have to:
- Register an action-type constant
- Write an action-crator
- Import it in the control and register
it in the mapDispatchToProps
- Write it in the controlCheckBoxClick and enter it in the
checkbox - Add Switch in the reducer with a case-function call
- Write a case-function in logic
For the sake of a boxing check! - The state that is generated by combineReducers is static. It does not matter if you entered module B or not, this piece will be in the stack. Empty, but will be. It is not convenient to use the inspector when there are a lot of unused empty nodes in the stack.
How we try to solve some of the problems described above.
So, we have stupid redusers, and in action-craytor / logic we write footcloths code to work with deeply embedded immutable structures. To get rid of this, I use the mechanism of hierarchical selectors, which allow not only access to the required piece of state, but also carry out its replacement (convenient setIn). I posted this in the immutable-selectors package .
Let's take a look at our example of how it works ( repository ):
In the poemScoring module, we will describe the object of selectors. We describe those fields from the state that we want to have direct read / write access to. Any nesting and parameters for accessing the elements of collections are allowed. It is not necessary to describe all possible fields in our stack.
import extendSelectors from'immutable-selectors';
const selectors = {
poemText:{},
score:{}
};
extendSelectors(selectors, [ 'poemScoring' ]);
exportdefault selectors;
Further, the extendSelectors method turns each field in our object into a selector function. The second parameter indicates the path to that part of the state, which is controlled by the selector. We do not create a new object, but modify the current one. This gives us a bonus in the form of a worker's intelligence:
What our object is - the selector after its expansion:
The selectors.poemText (state) function simply executes state.getIn (['poemScoring', 'poemText']) .
The root (state) function gets 'poemScoring'.
Each selector has its own function replace (globalState, newPart) , which through setIn returns a new global state with the corresponding part replaced.
Also, a flat object is added.in which all unique keys of the selector are duplicated. That is, if we use the deep state of the form
selectors = {
dive:{
in:{
to:{
the:{
deep:{}
} } } }}
You can get deep as selectors.dive.in.to.the.deep (state) or selectors.flat.deep (state) .
Go ahead. We need to update the data acquisition in the controls:
Poem:
functionmapStateToProps(state, ownprops) {
return {
text:selectors.poemText(state) || ''
};
}
Score:
functionmapStateToProps(state, ownprops) {
const score = selectors.score(state);
return {
score
};
}
Next, change the root reducer:
import initialState from'./initialState';
functionsetStateReducer(state = initialState, action) {
if (action.setState) {
return action.setState;
} else {
return state;
// return combinedReducers(state, action); //
}
}
exportdefault setStateReducer;
If desired, we can combine using combineReducers.
Action-caster, on the example of poemTextChange:
exportfunctionpoemTextChange(text) {
returnfunction (dispatch, getState) {
dispatch({
type: 'Poem typing',
setState: logic.onType(getState(), text),
payload: text
});
};
}
We can no longer use action-type constants, since type is now used only for visualization in the inspector. We in the project write full-text descriptions of the action in Russian. You can also get rid of payload, but I try to save it, so that in the inspector, if necessary, to understand with what parameters the action was called.
And, actually, the logic itself:
onType(gState, text) {
const { reductedText, censoredWords } = this.redactText(text);
const poemState = selectors.root(gState) || Immutable.Map(); // извлечение нужного куска стейтаconst newPoemState = poemState // мутация
.set('poemText', reductedText)
.set('score', this.calcScore(reductedText));
let newGState = selectors.root.replace(gState, newPoemState); // создание нового стейтаif (censoredWords) { // если требуется, стейт дополняем сообщениемconst userName = appSelectors.flat.userName(gState);
const messageText = `${userName}, avoid of using word ${censoredWords}, please!`;
newGState = message.showMessage(newGState, messageText);
}
return newGState;
},
At the same time, message.showMessage is imported from the logic of the neighboring module, which describes its selectors:
showMessage(gState, text) {
return selectors.message.text.replace(gState, text);
}.
What happens:
We note that we had one dispatch, the data changed in two modules.
All this allowed us to get rid of the reducers and action-type constants, as well as to solve or circumvent most of the bottlenecks indicated above.
How else can this be applied?
This approach is convenient to use when it is necessary to ensure that your controls or modules provide work with different pieces of the state. Suppose we have a little poem. We want the user to compose poems for different disciplines (children, romantic) on two parallel tabs. In this case, we can not import selectors in logic / controls, but indicate them as a parameter in the external control:
<Poem selectors = {selectors.сhildPoem}/>
<Poemselectors = {selectors.romanticPoem}/>
And, further, to transfer this parameter to action-craters. This is enough to make a fully complex combination of components and logic completely closed, making it easy to reuse.
Restrictions on using immutable-selectors:
It will not be possible to use a key in the “name” state, since the parent function will attempt to redefine the reserved property.
What is the result
As a result, a fairly flexible approach was obtained, the implicit code links by text constants were excluded, the overheads were reduced, while maintaining the convenience of development. Also remained fully functioning redux inspector with the possibility of time travel. I have no desire to return to standard reducers.
In general, everything. Thank you for your time. Maybe someone will be interested in trying out!