Redux Minus Redux With React Contexts

Sarah Port ·

When it comes to state management in the front end, Redux has long reigned supreme. With the development of Contexts, we’re now seeing the beginning of an alternative which is brand new, but also capable of being completely familiar for long-time Redux fans. Using React Contexts with Redux-like paradigms is a good way to embrace the flexibility that Contexts provides, and gives you a chance to try out some brand new technology without having to learn brand new state management tooling and ideas right away. In this article, I’ll give a detailed walkthrough of how I re-implemented familiar Redux interfaces for React Context data stores.

Creating the Basic Store

To begin with, let’s create our data store context in a new file called context.jsx. Here I’m following Kent Dodds’ advice and keeping the state and dispatch in separate contexts.

// context.jsx
import React, { createContext } from 'react';

export const StateContext = createContext();
export const DispatchContext = createContext();

With our contexts in place, we can create our state provider. This will make the state and dispatch available to their child components:

// context.jsx
import React, { createContext, useState } from 'react';
...
export const StateProvider = ({ children }) => {
  const [state, setState] = useState({ cookies: 0 });

  // the state and dispatch providers can be nested in either order
  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={setState}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
};

At this point we have a basic working store that we can connect our components to.

// App.jsx
import React from 'react';
import { StateProvider } from './context';
import Cookies from './Cookies';

const App = () => (
  <StateProvider>
    <Cookies />
  </StateProvider>
);

export default App;
// Cookies.jsx
import React, { useContext } from 'react';
import { StateContext, DispatchContext } from './context'

const Cookies = () => {
  const state = useContext(StateContext);
  const setState = useContext(DispatchContext);

  const { cookies } = state;

  const incrementCookies = () => {
    setState({ ...state, cookies: cookies + 1 });
  };

  return (<div>
    <div>{cookies}</div>
    <div><button onClick={incrementCookies}>More Cookies</button></div>
  </div>)
};

export default Cookies;

In this version, the contexts hold our state and our setState “dispatch” function. Context providers make this data available to its children, allowing those components to access state and setState via the useContext hook. When a user clicks the “More Cookies” button, our component makes a call to setState, and our state is updated with the new cookie value.
For a very simple application there is nothing wrong with this, but after writing a couple components like this I found that I really missed the good ol’ days of reducers and action creators in redux.

 

Adding Reducers and Actions

Thankfully React has a handy useReducer hook that will make borrowing that convention really simple. Start by writing our reducer and some action creators in the usual way:

// actions.js
export const UPDATE_COOKIES = 'UPDATE_COOKIES';

export const updateCookies = (cookies) => ({ type: UPDATE_COOKIES, data: cookies });
// reducer.js
import * as actions from './actions';

export const defaultState = { cookies: 0 };

export const reducer = (state, action) => {
  switch(action.type) {
    case actions.UPDATE_COOKIES: {
      return { ...state, cookies: action.data};
    }
    default: {
      return state;
    }
  }
};

Now you can use these in your context providers.

import React, { createContext, useReducer } from 'react';
import { defaultState, reducer } from './reducer';
...
export const StateProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, defaultState);
  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
};
// Cookies.jsx
...
import { updateCookies } from './actions';

const Cookies = () => {
  const state = useContext(StateContext);
  const dispatch = useContext(DispatchContext);

  const { cookies } = state;

  const incrementCookies = () => {
    dispatch(updateCookies(cookies + 1));
  };
  ...
};

At this point our app should be looking more familiar to Redux devs, but we still have context interfaces in every connected component. I’d like to take this a step further and create a connect function that we can hide all of our context interfaces in.

Writing a Connect Function

In our context file let’s start by writing a higher order component that takes a given component and injects our state and dispatch directly into the component props:

import React, { createContext, useContext, useReducer } from 'react';
...
export const connect = (Component) => (props) => {
  const state = useContext(StateContext);
  const dispatch = useContext(DispatchContext);

  return (<Component {...props} {...state} dispatch={dispatch} />);
};
// Cookies.jsx
import React from 'react';
import { connect } from './context';
import { updateCookies } from './actions';

const Cookies = ({cookies, dispatch}) => {
  const incrementCookies = () => {
    dispatch(updateCookies(cookies + 1));
  };
  ...
};

export default connect(Cookies);

If you’re still unhappy without your mapStateToProps and mapDispatchToProps, we can implement that too!

// context.jsx
...
export const connect = (
  mapStateToProps = () => ({}),
  mapDispatchToProps = (dispatch) => ({dispatch})
) => (Component) => (props) => {

  const state = useContext(StateContext);
  const dispatch = useContext(DispatchContext);

  const stateProps = mapStateToProps(state);
  const dispatchProps = mapDispatchToProps(dispatch);

  return (<Component {...props} {...stateProps} {...dispatchProps} />);
};
// Cookies.jsx
import React from 'react';
import { connect } from './context';
import { updateCookies } from './actions';

const Cookies = ({cookies, updateCookieCount}) => {
  const incrementCookies = () => updateCookieCount(cookies + 1)

  return (<div>
    <div>{cookies}</div>
    <div><button onClick={incrementCookies}>More Cookies</button></div>
  </div>)
};

const mapStateToProps = (state) => ({
  cookies: state.cookies,
});
const mapDispatchToProps = (dispatch) => ({
  updateCookieCount: (cookies) => dispatch(updateCookies(cookies)),
});

export default connect(mapStateToProps, mapDispatchToProps)(Cookies);

Now our connect function takes two additional optional parameters. When mapStateToProps is not provided, it returns no state to the component. When mapDispatchToProps is undefined, it returns the full dispatch function.

Adding Thunks for Async Actions

This solution got me almost all the way there, but there was still the problem of async actions. I wasn’t able to find any standard solutions for Contexts yet, but writing a Thunks-like custom middleware for the job turned out to be easier than I expected:

// middleware.js
export default (dispatch) => (action) => {
 if (typeof action === 'function') {
   action(dispatch);
 } else {
   dispatch(action);
 }
};
// context.js
...
import applyMiddleware from './middleware';

export const StateProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, defaultState);
  const enhancedDispatch = applyMiddleware(dispatch);

  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={enhancedDispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
};
...

Our middleware function is a simple second order function that takes a dispatch and produces a modified dispatch function. The modified dispatch checks whether the action is a function or not. If it is, then it calls the function with the given dispatch – these are our “thunks”. Otherwise, it’s a normal action and we dispatch it as usual. Now we can write and use async actions with familiar thunk syntax:

// actions.js
...
export const getAsyncCookies = () => async (dispatch) => {
  const response = await someAsyncFucntion();
  dispatch(updateCookies(response.data));
};
// Cookies.jsx
import React, { useEffect } from 'react';
import { connect } from './context';
import { updateCookies, getAsyncCookies } from './actions';

const Cookies = ({cookies, updateCookieCount, getInitialCookies}) => {
  useEffect(() => {
    getInitialCookies();
  }, []);
 
  const incrementCookies = () => updateCookieCount(cookies + 1)

  return (<div>
    <div>{cookies}</div>
    <div><button onClick={incrementCookies}>More Cookies</button></div>
  </div>)
};

const mapStateToProps = (state) => ({
  cookies: state.cookies,
});
const mapDispatchToProps = (dispatch) => ({
  updateCookieCount: (cookies) => dispatch(updateCookies(cookies)),
  getInitialCookies: () => dispatch(getAsyncCookies())
});

export default connect(mapStateToProps, mapDispatchToProps)(Cookies);

I hope that I’ve convinced you that Contexts can be used even by lifelong Redux fans. “Why not just use Redux?”, you might be asking, and that might still be the best option for you and your project. As a far more established tool, Redux does have a much richer ecosystem of tooling and add-ons than Contexts does. However, that doesn’t mean you should overlook Contexts entirely. Using familiar interfaces like these lowers the risk in adopting new technologies while allowing access to the unique benefits that Contexts can provide.

 

______________________________________________________________________________

 

Join the Carbon Five Team collage of group photos with link to carbonfive.com/careers

We’re hiring! Looking for software engineers, product managers, and designers to join our teams in SF, LA, NYC, CHA.

Learn more and apply at www.carbonfive.com/careers