loading...

Advanced Reducers in Redux

fosteman profile image Timothy Fosteman ・4 min read

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:

  1. Window reducer (that controls IoT window frames in a Smart House)
  2. 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:

  1. A reducer can care about different action types
  2. 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.

Discussion

pic
Editor guide