Nothing is mandatory, however, advised surely.
I outline best practices, common usage patterns and app-scaling in state management architecture, powered by Redux.
With growing functionality, reducers would scale horizontally adding up to action types with linear rate. To acquire additional scalability, reducer is split up into smaller reducers, each concerned of specific domain (semantic) problem. Thus, I'd split my humongous reducer on:
- Window reducer (that controls IoT window frames in a Smart House)
- Thermostat reducer
Pattern here is extrapolation of responsibilities on domain-dedicated reducers - combined reducers. Redux gives us combineReducer()
helper to produce rootReducer
, being later initialized in a state. This function takes an object as input that has a state as property name and a reducer as value
const rootReducer = combineReducers({
windowState: window
Initial State
const store = createStore(reducer, []);
Empty list []
is the initial state adopted by Redux Store on initialization, afterwards the Store will run through each reducer once.
Initializing action, that is received in the reducer, can also be accompanied by the initial state, following that leaving out initial state in the store initialization will turn it undefined
.
const store = createStore(reducer);
Initial state can be specified with ES6 default parameter
function reducer(state = [], action) {
...
}
Nested Data Structures
If initial state is an empty window list, empty list is a fine initial value, however, regard properties like: auth_token
, reason
, toggle
, errorStatus
, currentTemperature
to appear instantaneously after first development iteration.
To define initial state beyond reducer's default parametrized one
const initialStore = {
auth_token: null,
thermostat: {
currentTemperature: 0.0
},
windows: [
{id: 0, toggle: false},
{id: 1, toggle: false}
],
};
const store = createStore(reducer, initialState);
Two reducers operate on windows
list, and on thermostat
object
function applyShutWindows(state, action) {
return state.windows.map(window => action.windows.find(action.id)
? Object.assign({}, window, {toggle: false})
: window);
}
function applySetTemperature(state, action) {
return Object.assign({}, state.thermostat, { currentTemperature: action.temp });
}
Nested data structures are reasonable, and are generally fine in Redux, but try to avoid deeply nested structures, since, as you may regard, complexity grows - readability falls.
Though a neat normalizr
library available on npm, solves deep nesting concern
https://www.npmjs.com/package/normalizr
Combined Reducer
Scaling state by using substates in Redux.
Probably you've read about multiple reducers from official Documentation
Reducer switch
can grow horizontally with addition of action types, but it's linear scaling. If to split reducers up on semantic (domain-related) categories as follows: thermostat, windows control - scaling is now feasible. This approach is guaranteed to scale well beyond linear growth.
Pattern here is extrapolation of responsibilities on domain-dedicated reducers - combined reducers. Redux gives us combineReducer
helper to produce rootReducer
, being later initialized in a state. This function takes an object as input that has a state as property name and a reducer as value
Combine Reducers
Redux library provides a helper function combineReducers
that takes object as input that describes substate as property name and reducer as value
const rootReducer = combineReducers({
windowsState: windowsReducer,
thermostatState: thermostatReducer,
});
const store = createStore(rootReducer);
Individual reducers for windows and thermostat
function windowsReducer(state = [
{id: 0, toggle: true},
{id: 1, toggle: false}
], action) {
switch(action.type) {
case SHUT_WINDOWS: {
return applyShutWindows(state, action);
}
default: return state;
}
function thermostatReducer(state = { currentTemperature: 0.0 }, action) {
switch(action.type) {
case SET_TEMP: {
return applySetTemp(state, action);
}
default: return state;
}
combineReducers
introduces an intermediate state layer (substate) for the global state object. The global state object, when using combined reducers, will look according to the object passed to combineReducers
{
windowsState: ...,
thermostatState: ...,
}
The property keys for the intermediate layer are those keys defined in the object passed to combineReducers()
. Mind that default parameter in reducer no longer affects this global state, only it's own substate, furthermore, substates are isolated and have no idea about each other, nor of the global state. Individual reducers can now operate on root state
variable since they are inside their own substate
function applyShutWindows(state, action) {
return state.map(window => action.windows.find(action.id)
? Object.assign({}, window, {toggle: false})
: window);
}
function applySetTemperature(state, action) {
const thermostatUpdate = Object.assign({}, state, { currentTemperature: action.temp });
return Object.assign({}, state, thermostatUpdate);
}
It seems like an unnecessary hassle, but it's critical to enable non-linear state scaling, substates empower codebase maintainability
Clarification for Initial State
The initial state in createStore
prevails reducer's default parameter
→ In case of one plain reducer, the initial state in the reducer only works when the incoming initial state is undefined
because only then can it apply a default state.
Note: if initial state is already defined in createStore()
- reducer will not compete against it.
→ On the other hand, when using combined reducers: it's better to embrace default parametrized usage of the substate initialization.
Note: initial state passed in createStore()
doesn’t have to include all substates that are introduced by combineReducers()
Therefore, when a
substate is undefined
, the reducer can define it's own default substate. Otherwise the default object hierarchy from the createStore()
is utilized..
Nested Reducers
Scaling reducers horizontally, although with an additional vertial level in combined one, boils down to:
- A reducer can care about different action types
- A reducer can be split up into multiple reducers yet be combinet as one root reducer for the store initialization
Sometimes, in case of complex domain problem under trial, it's reasonable to introduce a new vertical layer of reducers. Take 'IoT window frames' example, and imagine those windows to also feature an electrically controlled tint layer.
function windowsReducer(state = [
{id: 0, toggle: true, tint: { degree: 0.0 }},
{id: 1, toggle: false, tint: { degree: 0.0 }}
], action) {
switch(action.type) {
case SHUT_WINDOWS: {
return applyShutWindows(state, action);
}
case TINT_WINDOWS: {
//Vertical expansion
//tintReducer will tap into it's boundaries of substate.
return tintReducer(undefined, action);
}
default: return state;
}
function tintReducer(state, action) {
switch(action.type) {
case TINT_WINDOWS: {
return applyTintWindows(state, action);
}
default : return state;
}
}
Conclusion
That's all about usage of plain Redux. It helps to manage a predictable singleton state object. The principle of Flux behind Reducing the Store is universal and hence may be deployed to any SPA (React, Vue, Angular). Surely, in simple React applications, local state may suffice the needs of the service, however, with releasing a cross-platform version, with bunch of API to be fetched and analytics to be triangulated - it's a safe bet to go with Redux to organize all the spaghetti into ranks and files.
Top comments (0)