Did you Redux too soon? React 16.3 context updates

May 28, 2018

The need

When crafting React apps we usually are eager to pull in Redux so that we can isolate the app state from the components and avoid state management that is kind of tedious and iffy due to the fact that some times the state needs to pass through intermediate components that do not even care about that part of the state or even state in general. Redux works great in that but it comes with a lot of boilerplate. React 16.3 introduces a new context API that allows centralised state management without having to inject the same props down to 10000 children.

Inception

The concept

I am going to briefly demonstrate how someone can achieve app state management with only React, thusly avoiding pulling in extra libraries when it is deemed unnecessary. Our app is going to be a textfield whose value is going to be synced in different parts of the React component tree without having to pass a single prop to intermediate children. Before getting to code you need to get familiar with the context API. React context consists of:

  • a <Provider /> which injects a value down the Component tree
  • a <Consumer /> which consumes a Provider to get its value in a specific component

To the code!

The state

// AppWithState.jsx
import React, { Component, createContext } from 'react'
import App from './App'

const { Provider, Consumer } = createContext('app-state')

class AppWithState extends Component {
  constructor(props) {
    super(props)
    this.state = {
      root: { txt: '', sync: true }
    }
    this.actions = {
      updateText: this.updateText,
      toggleSync: this.toggleSync
    }
  }

  updateText = txt => {
    this.state.root.sync &&
      this.setState(state => ({
        root: { ...state.root, txt }
      }))
  }

  toggleSync = txt => {
    this.setState(state => ({
      root: {
        ...state.root,
        sync: !state.root.sync,
        txt: !state.root.sync ? txt : state.root.txt
      }
    }))
  }

  render() {
    return (
      <Provider value={{ state: this.state, actions: this.actions }}>
        <App />
      </Provider>
    )
  }
}

export { Consumer }
export default AppWithState

<AppWithState> is our main component which is rendered to the DOM with ReactDOM.render. It defines the Consumer and Provider by calling createContext with a default value which is only used when a Consumer does not find a Provider as an ancestor in the tree. The constructor defines the state and the actions to update our state.

The state injection and updating

In a component far, far away:

// Main.jsx
import React from 'react'
import { DefaultButton } from 'office-ui-fabric-react/lib/Button'
import { TextField } from 'office-ui-fabric-react/lib/TextField'

import { Consumer as AppStateConsumer } from '../AppWithState'

const renderMain = ({
  state: {
    root: { txt, sync }
  },
  actions: { updateText, toggleSync }
}) => {
  return (
    <main>
      <h1 style={{ marginBottom: 25 }}>{txt || 'Heading'}</h1>
      <div style={{ marginBottom: 25 }}>
        <DefaultButton
          primary={sync}
          onClick={() => {
            txt.toLowerCase() === 'end'
              ? updateText('Thank you! Context rocks.')
              : toggleSync(this.textFieldRef.value)
          }}
        >
          {sync ? 'Broadcasting' : 'Off'}
        </DefaultButton>
      </div>
      <TextField
        label={
          sync ? "I 'm broadcasting everywhere" : "I 'm just a simple textfield"
        }
        componentRef={ref => (this.textFieldRef = ref)}
        onChanged={val => updateText(val)}
      />
    </main>
  )
}

export default () => <AppStateConsumer>{renderMain}</AppStateConsumer>

The <Consumer /> always has a function as its child in order to inject the <Provider /> state to the Component. We pick whatever we need from the state and use it in our component. Notice that we are also using the actions injected from context in order to update the centralised state which lies in <AppWithState /> when certain events occur like an input changing its text or the click of a button.

The verdict

In the same manner, someone can use the app state in other components which introduces a clean and efficient pattern of state management by using only React. Someone can realise that this can scale further by introducing state modules. For example instead of having only one state component (<AppWithState />), we could have a <RootState /> for the main app state properties and a <UserState /> which would hold everything related to a user session (auth token, IDs, etc.) in the same manner that we split the app state to reducers when using Redux.

It is up to the developer to realize what fits his needs. While the context approach for state management is declarative and clear enough, it binds the app state further to a specific framework (React). Further, the Redux boilerplate and conventions as well as its functional concepts which isolate side effects in reducers enhance the testability and predictability of the codebase which is critical to large scale applications.

Eleftherios Pegiadis
Front-end Engineer
In the case you this post, I want to hear about it in the comments below. In the case that you didn't like it, found it offensive or disagreed with something written in it, again don't hesitate to leave a comment below or contact me at pegiadis@agileturtles.gr
Thanks for reading
Code snippets licensed under MIT, unless otherwise noted.
Content © 2024 - Eleftherios Pegiadis