Redux – Not needed! Replacing with useContext and useReducer in React?

Good day, Habr readers!

I want to tell you about how I recently found out about some “hooks” in React. They appeared relatively recently, in version [16.8.0] of
February 6, 2019 (which in terms of FrontEnd evolution speed – is very long ago)

Having read the documentation, I focused my attention on useReducer hook and immediately asked myself: “Is this something that could fully replace Redux!?”. I spent a few nights experimenting and now I want to share the results and my conclusions.

Do I need to replace Redux with useContext + useReducer?

For the impatient – here are the conclusions

Pros:

  • You can use hooks (useContext + useReducer) instead of Redux in smaller applications (where there is no need for large combined Reducers). In this case, Redux may indeed be redundant.

Cons:

  • A large amount of code is already written on the React + Redux bundle and rewriting it on hooks (useContext + useReducer) does not seem appropriate to me, at least for now.
  • Redux is a proven library, hooks are an innovation, their interfaces and behavior may change in the future.
  • To make using useContext + useReducer really convenient, you will have to write some hacks.

These conclusions are the author’s personal opinions and don’t claim to be the absolute truth – if you don’t agree, I’ll be glad to see your constructive criticism in the comments.

Let’s try to figure it out

Let’s start with a simple example

(reducer.js)

import React from "react";
export const ContextApp = React.createContext();

export const initialState = {
    app: {
        test: 'test_context'
    }
};

export const testReducer = (state, action) => {
    switch(action.type) {
        case 'test_update':
            return {
                ...state,
                ...action.payload
            };
        default:
            return state
    }
};

So far, our reducer looks exactly the same as in Redux

(app.js)

import React, {useReducer} from 'react'
import {ContextApp, initialState, testReducer} from "./reducer.js";
import {IndexComponent} from "./IndexComponent.js"

export const App = () => {
    // Initializing the reducer and getting state + dispatch for writing
    const [state, dispatch] = useReducer(testReducer, initialState);

    return (
        // To enable us to use reducer in components
        // we will use ContextApp and pass (dispatch and state)
        // to the components that are lower in the hierarchy
        <ContextApp.Provider value={{dispatch, state}}>
            <IndexComponent/>
        </ContextApp.Provider>
    )
};

(IndexComponent.js)

import React, {useContext} from "react";
import {ContextApp} from "./reducer.js";

export function IndexComponent() {
    // Using the useContext function to get the ContextApp context
    // The component IndexComponent must be wrapped with ContextApp.Provider
    const {state, dispatch} = useContext(ContextApp);

    return (
            // Using dispatch we end up in reducer.js in the testReducer method 
            // which updates the state. Just like in Redux
            <div onClick={() => {dispatch({
                type: 'test_update',
                payload: {
                    newVar: 123
                }
            })}}>
                {JSON.stringify(state)}
            </div>
    )
}

That’s the simplest example, in which we simply update write new data into a flat (without nesting) reducer. In theory, we can even try to write it like this:

(reducer.js)

...
export const testReducer = (state, data) => {
    return {
        ...state,
        ...data
    }
...

(IndexComponent.js)

...
return (
            // Now we just send new data, without specifying type
            <div onClick={() => {dispatch({
                    newVar: 123
            }>
                {JSON.stringify(state)}
            </div>
    )
...

If we have a big and simple application (which in reality happens infrequently), we don’t have to use type and always control the update of the reducer directly from action. By the way, about updates, in this case we are only writing new data in the reducer, what if we have to change one value in a tree with several levels of nesting?

Now a little more complicated

Let’s look at the following example:

(IndexComponent.js)

...
return (
            // Now we want to update data inside of a tree
            // for that we need to get the most current state
            // of this tree at the moment of the action call, we can do it through a callback:
            <div onClick={() => {
            // Let's make the action return a callback
            // which inside of testReducer is going to pass the most current state
             (state) => {
             const {tree_1} = state;

             return {
                tree_1: {
                    ...tree_1,
                    tree_2_1: {
                        ...tree_1.tree_2_1,
                        tree_3_1: 'tree_3_1 UPDATE'
                    },
                },
            };
            }>
                {JSON.stringify(state)}
            </div>
    )
...

(reducer.js)

...
export const initialState = {
    tree_1: {
        tree_2_1: {
            tree_3_1: 'tree_3_1',
            tree_3_2: 'tree_3_2'
        },
        tree_2_2: {
            tree_3_3: 'tree_3_3',
            tree_3_4: 'tree_3_4'
        }
    }
};

export const testReducer = (state, callback) => {
    // Now we must get the current state inside the action that we are initializing
    // we can do that through a callback
    const action = callback(state);

    return {
        ...state,
        ...action 
    }
...

Ok, we figured out how to do an update of the tree. Although in this case, it’s better to go back to using types inside of testReducer and update the tree by the type of action. Everything like in Redux, only the resultant bundle is a bit smaller [8].

Asynchronous operations and dispatch

But is everything so good? What happens if we go to use asynchronous operations? For that, we have to define our own dispatch. Let’s try it!

(action.js)

export const actions = {
    sendToServer: function ({dataForServer}) {
        // For this, we have to return a function that takes dispatch
        return function (dispatch) {
            // And inside dispatch return a function
            // which takes state just like in previous examples
            dispatch(state => {
                return {
                    pending: true
                }
            });
       }
    }

(IndexComponent.js)

const [state, _dispatch] = useReducer(AppReducer, AppInitialState);
// To have the possibility of calling dispatch from action ->
// It's necessary to pass it there, we'll write a Proxy
const dispatch = (action) => action(_dispatch);
...
dispatch(actions.sendToServer({dataForServer: 'data'}))
...

Seems like everything is ok, but now we have a large nesting of callbacks, which isn’t very cool, if we want to simply change state without creating action functions, we have to write a construction of this kind:

(IndexComponent.js)

...
dispatch(
                    (dispatch) =>
                        dispatch(state => {
                            return {
                                {dataForServer: 'data'}
                            }
                        })
                )
...

It turns into something scary, doesn’t it? For simple update of data I would really like to write something like this:

(IndexComponent.js)

...
dispatch({dataForServer: 'data'})
...

For that, we have to change Proxy for the dispatch function, that we created earlier

(IndexComponent.js)

const [state, _dispatch] = useReducer(AppReducer, AppInitialState);
// Change
// const dispatch = (action) => action(_dispatch);
// To
const dispatch = (action) => {
        if (typeof action === "function") {
            action(_dispatch);
        } else {
            _dispatch(() => action)
        }
    };
...

Now we can pass into dispatch both an action function, as well as a simple object. But! When simply passing an object you must be careful, you may be tempted to do this:

(IndexComponent.js)

...
dispatch({
    tree: {
        // We have access to state within any component in AppContext
        ...state.tree,
        data: 'newData'
    }
})
...

What’s wrong with that example? The fact that at the moment of processing of this dispatch, state could have been updated through a different dispatch, but those changes haven’t made it to our component yet and in fact we are using an old instance state, that will overwrite all the old data.

For that reason, this method becomes applicable in very few places, only for updating of flat reducers in which there is no nesting and you do not need to refer to state to update nested objects. In reality, reducers rarely happen to be perfectly flat, so I would advise not to use this method at all and update the data only through actions.

(action.js)

...
            // Since dispatch always sends a callback, inside this callback
            // we always have the most current state (см. reducer.js)
            dispatch(state => {
                return {
                    dataFromServer: {
                        ...state.dataFromServer,
                        form_isPending: true
                    }
                }
            });

            axios({
                method: 'post',
                url: `...`,
                data: {...}
            }).then(response => {
                dispatch(state => {
                    // Even if the axios request was executed for a few seconds
                    // and several dispatches from other places in the code were executed
                    // in this interval, this state will always be the most current,
                    // since we get it directly from testReducer (reducer.js)
                    return {
                        dataFromServer: {
                            ...state.dataFromServer,
                            form_isPending: false,
                            form_request: response.data
                        },
                        user: {}
                    }
                });
            }).catch(error => {
                dispatch(state => {
                    // Similarly, state is as fresh as morning fresh)
                    return {
                        dataFromServer: {
                            ...state.dataFromServer,
                            form_isPending: false,
                            form_request: {
                                error: error.response.data
                            }
                        },
                    }
                });
...

Conclusion

  • It was an interesting experience, I strengthened my academic knowledge and learned some new features of react
  • I will not use this approach in production (at least for the next six months). For the reasons already described above (this is a new feature, and Redux is a proven and reliable tool) + I don’t have any performance problems chasing milliseconds that can be won by giving up Redux [8]

I will be glad to find out, in comments, the opinion of colleagues from the front-end part of our Habr Community!

Links:

What about weight and performance?

I wasn’t suprised when the bundle shrank by 12 KB: with Redux-166, without it-154. This is logical, less dependencies means less weight.

But the increase in the speed of processing actions and rendering surprised me slightly. I was taking measurements with console.time and performance.measure. The average values for 100 iterations came out as follows:

console.timeperformance.measure
Redux12 ms13 ms
Context + hooks9 ms8 ms

Written by it-efrem, translated from here. Read more articles from the author at it-efrem.com