DEV Community

Cover image for Sharing state like Redux with React's Context API
Rohan Faiyaz Khan
Rohan Faiyaz Khan

Posted on • Edited on • Originally published at rohanfaiyaz.com

Sharing state like Redux with React's Context API

The pains of growing state

In learning React, one of the first challenges I faced was figuring out state management. State is a vital part of any application that has more complexity than a simple blog or brochure site. React has a fantastic toolset to manage component level state both in the case of functional components with hooks, and class based components. However global state is a bit of a different story.

Almost every advanced feature such as authentication, shopping carts, bookmarks etc. heavily rely on state that multiple components need to be aware of. This can be done by passing state through props but as an application grows this gets complicated very fast. We end up having to pipe state through intermediary components and any change in the shape of the state needs to reflected in all of these components. We also end up with a bunch of code unrelated to the concern of the intermediary component, so we learn to ignore it. And if Uncle Bob taught me anything, the code we ignore is where the bugs hide.

The solution: Redux

Redux was born out of the problem of global state handling. Built by Dan Abramov and his team, Redux provided a global store independant of local state that individual components could access. Furthermore it comes with some high level abstractons for dealing with state, such as the state reducer pattern.

Wait, slow down, the state reducer what now?

Yes I hear you, for this was my exact reaction when I heard of these words put together for the first time. The reducer pattern is a popular pattern even outside of Redux, and implements a way to change state. A reducer function is a pure function (i.e. has no external state or side effects) that simply takes in the previous state and an action, and returns the new state. It looks like this below.

function reducer(state, action){
    switch(action){
        case "increment":
            return state + 1
        case "decrement":
            return state - 1
        default:
            return state
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern allows us to alter state predictably which is important because we need to how our application might react to changes in state. Under the pattern, mutating state directly is heavily discouraged.

Redux also provides us with the action creator pattern, which is simply a way to organise how we dispatch our actions. Combined with the state reducer pattern, this gives us great tools to organize our global state management.

Sounds good, so what's the problem?

While redux is great and I personally am a big fan of it, it has its fair share of detractors.

  • The first problem a lot of people have is that it is very boilerplate-y. This is especially apparent when you have an app that initially doesn't need global state, and then later on you realize you do and then *BOOM* 200+ lines added in one commit. And every time global state has to be pulled in for a component, this extra boilerplate has to added in.

  • Redux is opinionated and imposes limitations. Your state has to be represented as objects and arrays. Your logic for changing states have to be pure functions. These are limitations that most apps could do without.

  • Redux has a learning curve of its own. This true for me personally, because React seemed very fun as a beginner until I hit the wall of Redux. These advanced high level patterns are something a beginner is not likely to appreciate or understand.

  • Using Redux means adding about an extra 10kb to the bundle size, which is something we would all like to avoid if possible.

Several other state management libraries have propped up such as MobX to solve the shortcomings of Redux, but each have their own trade-offs. Furthermore, all of them are still external dependencies that would bulk up the bundle size.

But surely something this important has a native implementation? Right?

Well there wasn't, until...

All hail the magical context!

To be fair, the Context API has been around for a while, but it has gone through significant changes and alterations before it became what it is today. The best part about it is that it does not require any npm install or yarn install, it's built in with React, I personally have found the current iteration of the Context API to be just as powerful as Redux, especially when combined with hooks.

But there was a roadblock to learning, that being the official React documentation is terrible at explaining how powerful the Context API is. As a result, I dug through it and implemented a simple login system so that you don't have to.

Enough talk, show me how this works already

All we will be doing is logging in (using a fake authentication method wrapped in a Promise), and changing the title with the username of the logged in user. If you'd rather skip all the explanantion and just look at the code, feel free to do so.

Demo of example authentication application

The first thing we need to do to use context is React.createContext(defaultValue). This is a function that returns an object with two components:

  • myContext.Provider - A component that provides the context to all its child elements. If you have used Redux before, this does the exact same thing as the Provider component in the react-redux package
  • myContext.Consumer - A component that is used to consume a context. As we shall soon see however, this will not be needed when we use the useContext hook

Lets use this knowledge to create a store for our state.

// store.js

import React from 'react';

const authContext = React.createContext({});

export const Provider = authContext.Provider;
export const Consumer = authContext.Consumer;
export default authContext;
Enter fullscreen mode Exit fullscreen mode

Notice below that the defaultValue parameter passed to createContext is an empty object. This is because this parameter is optional, and is only read when a Provider is not used.

Next we have to wrap our application in the Provider so that we can use this global state. Provider needs a prop called value which is the value of the state being shared. We can then use the useContext hook in the child component to retrieve this value.

function App(){
    return (
        <Provider value={someValue}>
            <ChildComponent />
        </Provider>
    )
}

function ChildComponent(){
    const contextValue = useContext(myContext)
    return <div>{contextValue}</div>
}
Enter fullscreen mode Exit fullscreen mode

However you might notice a problem with this method. We can only change the value of the state in the component containing the Provider. What if we want to trigger a state change from our child component?

Well remember the reducer state pattern I talked about above? We can use it here! React provides a handy useReducer hook which takes in a reducer function and an initialState value and returns the current state and a dispatch method. If you have used redux before, this is the exact same reducer pattern we would observe there. Then we have pass the return value of the useReducer hook as the value inside <Provider>.

Lets define a reducer.

// reducers/authReducer

export const initialAuthState = {
    isLoggedIn: false,
    username: '',
    error: ''
};

export const authReducer = (state, action) => {
    switch (action.type) {
        case 'LOGIN':
            return {
                isLoggedIn: true,
                username: action.payload.username,
                error: ''
            };
        case 'LOGIN_ERROR':
            return {
                isLoggedIn: false,
                username: '',
                error: action.payload.error
            };
        case 'LOGOUT':
            return {
                isLoggedIn: false,
                username: '',
                error: ''
            };
        default:
            return state;
    }
};
Enter fullscreen mode Exit fullscreen mode

Now we can use our reducer in our <Provider>.

// App.js 

import React, { useReducer } from 'react';
import Router from './components/Router';
import { Provider } from './store';
import { authReducer, initialAuthState } from './reducers/authReducer';

function App() {
    const useAuthState = useReducer(authReducer, initialAuthState);
    return (
        <Provider value={useAuthState}>
            <Router />
        </Provider>
    );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Now all components in our application will have access to the state and the dispatch method returned by useReducer. We can now use this dispatch method in our login form component. First we will grab the state from our context so we can check if the user is logged in so we can redirect them or if we need to render an error. Next we will attempt to login (using our fake authentication method) and dispatch an action based on either authentication is successful or not.

// components/LoginForm.jsx

import React, { useState, useContext, Fragment } from 'react';
import { Link, Redirect } from 'react-router-dom';
import authContext from '../store';
import attemptLogin from '../auth/fakeAuth';

const LoginForm = () => {
    const [ state, dispatch ] = useContext(authContext);
        const { isLoggedIn, error } = state;

    const [ fakeFormData, setFormData ] = useState({
            username: "Rohan", 
            password: "rohan123"
        });

    function onSubmit(event) {
        event.preventDefault();
        attemptLogin(fakeFormData)
            .then((username) => {
                dispatch({
                    type: 'LOGIN',
                    payload: {
                        username
                    }
                });
            })
            .catch((error) => {
                dispatch({
                    type: 'LOGIN_ERROR',
                    payload: {
                        error
                    }
                });
            })
            .finally(() => {
                setLoading(false);
            });
    }

    return (
        <Fragment>
            {isLoggedIn ? (
                <Redirect to="/" />
            ) : (
                <Fragment>
                    {error && <p className="error">{error}</p>}
                    <form onSubmit={onSubmit}>
                        <button type="submit">Log In</button>
                    </form>
                </Fragment>
            )}
        </Fragment>
    );
};

export default LoginForm;
Enter fullscreen mode Exit fullscreen mode

Finally we will wrap up the landing component to show the logged in user's username. We will also toggle the welcome message to prompt a login or logout based on whether the user is already logged in or not, and will create a method to dispatch a logout.

// components/Hello.jsx

import React, { Fragment, useContext } from 'react';
import { Link } from 'react-router-dom';
import Header from './Header';
import authContext from '../store';

const Hello = () => {
    const [ { isLoggedIn, username }, dispatch ] = useContext(authContext);
    const logOut = () => {
        dispatch({
            type: 'LOGOUT'
        });
    };
    return (
        <Fragment>
            <Header>{`Well hello there, ${isLoggedIn ? username : 'stranger'}`}</Header>
            {isLoggedIn ? (
                <p>
                    Click <Link to="/" onClick={logOut}>here</Link> to logout
                </p>
            ) : (
                <p>
                    Click <Link to="/login">here</Link> to login
                </p>
            )}
        </Fragment>
    );
};

export default Hello;
Enter fullscreen mode Exit fullscreen mode

And there you have it

We now have a fully functioning context-based state management system. To summarize the steps needed to create it:

  • We created a store using React.createContext()
  • We created a reducer using the useReducer hook
  • We wrapped our application in a Provider and used the reducer as the value
  • We used the useContext to retrieve the state and dispatched actions when necessary

You might be asking now whether this can completely replace Redux. Well, maybe. You might notice that we had to implement our own abstractions and structure when using the Context API. If your team is already used to the Redux way of doing things, then I don't see a lot of value in switching. But if you or your team does want to break away from Redux I would certainly recommend giving this a try.

Thank you for reading, and I hope you found this post useful.

Top comments (8)

Collapse
 
rohanfaiyazkhan profile image
Rohan Faiyaz Khan

Hi! You are right in spotting that useReducer returns state and dispatch. useContext returns the context value provided by the Provider component. However in my App component I actually used the useReducer as the value for Provider.

function App() {
    const useAuthState = useReducer(authReducer, initialAuthState);
    return (
        <Provider value={useAuthState}>
            <Router />
        </Provider>
    );
}

As a result, the state returned by useContext inside the Provider's children is actually useReducer's output, i.e. state and dispatch. This allows us to call dispatch from the child components.

Hope this helps!

Collapse
 
wizhippo profile image
Douglas Hammond

How would this apply to multiple roots? For example converting a legacy application to use react where your components may not all reside under a single root. I believe this is where a store based on react context cannot emulate a redux store.

Collapse
 
rohanfaiyazkhan profile image
Rohan Faiyaz Khan

I'm sorry but I don't quite understand your question. Every React app has a single root even if you have routing involved.

Collapse
 
takeshitsunodax profile image
Takeshi Tsunoda

Thanks a lot, Rohan. Very well explained 👍

Collapse
 
omrisama profile image
Omri Gabay

So you're in favor of using a bunch of modular contexts at the lowest level possible for each component subtree?

Collapse
 
rohanfaiyazkhan profile image
Rohan Faiyaz Khan

You mean as opposed to having a singular application wide store?

Well Context API has better performance when split up into more modular contexts. Whether that is the right approach for an application is more of a difficult question. I guess it depends on the team's mindset, but I personally prefer a single redux store for large projects due to it being more flexible.

Collapse
 
omrisama profile image
Omri Gabay

Oh yeah, a single Redux store is definitely easier to grok from a mental model perspective. Thanks for sharing.

Collapse
 
mcabreradev profile image
Miguelángel Cabrera

this post help me a lot!!