21

I have updated this with an update at the bottom

Is there a way to maintain a monolithic root state (like Redux) with multiple Context API Consumers working on their own part of their Provider value without triggering a re-render on every isolated change?

Having already read through this related question and tried some variations to test out some of the insights provided there, I am still confused about how to avoid re-renders.

Complete code is below and online here: https://codesandbox.io/s/504qzw02nl

The issue is that according to devtools, every component sees an "update" (a re-render), even though SectionB is the only component that sees any render changes and even though b is the only part of the state tree that changes. I've tried this with functional components and with PureComponent and see the same render thrashing.

Because nothing is being passed as props (at the component level) I can't see how to detect or prevent this. In this case, I am passing the entire app state into the provider, but I've also tried passing in fragments of the state tree and see the same problem. Clearly, I am doing something very wrong.

import React, { Component, createContext } from 'react';

const defaultState = {
    a: { x: 1, y: 2, z: 3 },
    b: { x: 4, y: 5, z: 6 },
    incrementBX: () => { }
};

let Context = createContext(defaultState);

class App extends Component {
    constructor(...args) {
        super(...args);

        this.state = {
            ...defaultState,
            incrementBX: this.incrementBX.bind(this)
        }
    }

    incrementBX() {
        let { b } = this.state;
        let newB = { ...b, x: b.x + 1 };
        this.setState({ b: newB });
    }

    render() {
        return (
            <Context.Provider value={this.state}>
                <SectionA />
                <SectionB />
                <SectionC />
            </Context.Provider>
        );
    }
}

export default App;

class SectionA extends Component {
    render() {
        return (<Context.Consumer>{
            ({ a }) => <div>{a.x}</div>
        }</Context.Consumer>);
    }
}

class SectionB extends Component {
    render() {
        return (<Context.Consumer>{
            ({ b }) => <div>{b.x}</div>
        }</Context.Consumer>);
    }
}

class SectionC extends Component {
    render() {
        return (<Context.Consumer>{
            ({ incrementBX }) => <button onClick={incrementBX}>Increment a x</button>
        }</Context.Consumer>);
    }
}

Edit: I understand that there may be a bug in the way react-devtools detects or displays re-renders. I've expanded on my code above in a way that displays the problem. I now cannot tell if what I am doing is actually causing re-renders or not. Based on what I've read from Dan Abramov, I think I'm using Provider and Consumer correctly, but I cannot definitively tell if that's true. I welcome any insights.

Andrew
  • 12,936
  • 13
  • 52
  • 102

2 Answers2

15

There are some ways to avoid re-renders, also make your state management "redux-like". I will show you how I've been doing, it far from being a redux, because redux offer so many functionalities that aren't so trivial to implement, like the ability to dispatch actions to any reducer from any actions or the combineReducers and so many others.

Create your reducer

export const initialState = {
  ...
};

export const reducer = (state, action) => {
  ...
};

Create your ContextProvider component

export const AppContext = React.createContext({someDefaultValue})

export function ContextProvider(props) {

  const [state, dispatch] = useReducer(reducer, initialState)

  const context = {
    someValue: state.someValue,
    someOtherValue: state.someOtherValue,
    setSomeValue: input => dispatch('something'),
  }

  return (
    <AppContext.Provider value={context}>
      {props.children}
    </AppContext.Provider>
  );
}

Use your ContextProvider at top level of your App, or where you want it

function App(props) {
  ...
  return(
    <AppContext>
      ...
    </AppContext>
  )
}

Write components as pure functional component

This way they will only re-render when those specific dependencies update with new values

const MyComponent = React.memo(({
    somePropFromContext,
    setSomePropFromContext,
    otherPropFromContext, 
    someRegularPropNotFromContext,  
}) => {
    ... // regular component logic
    return(
        ... // regular component return
    )
});

Have a function to select props from context (like redux map...)

function select(){
  const { someValue, otherValue, setSomeValue } = useContext(AppContext);
  return {
    somePropFromContext: someValue,
    setSomePropFromContext: setSomeValue,
    otherPropFromContext: otherValue,
  }
}

Write a connectToContext HOC

function connectToContext(WrappedComponent, select){
  return function(props){
    const selectors = select();
    return <WrappedComponent {...selectors} {...props}/>
  }
}

Put it all together

import connectToContext from ...
import AppContext from ...

const MyComponent = React.memo(...
  ...
)

function select(){
  ...
}

export default connectToContext(MyComponent, select)

Usage

<MyComponent someRegularPropNotFromContext={something} />

//inside MyComponent:
...
  <button onClick={input => setSomeValueFromContext(input)}>...
...

Demo that I did on other StackOverflow question

Demo on codesandbox

The re-render avoided

MyComponent will re-render only if the specifics props from context updates with a new value, else it will stay there. The code inside select will run every time any value from context updates, but it does nothing and is cheap.

Other solutions

I suggest check this out Preventing rerenders with React.memo and useContext hook.

pedrobern
  • 700
  • 5
  • 21
  • This seems like a good approach, but consider having lots of components using useMemo, it will be a performance overhead I think. right? – Arman Jun 13 '20 at 08:51
  • 1
    The useMemo can save a lot of computing time if you have complex and big components, but for simple cases yes it is easier to just let rerendering, without the performance boost. – pedrobern Jun 13 '20 at 13:03
1

To my understanding, the context API is not meant to avoid re-render but is more like Redux. If you wish to avoid re-render, perhaps looks into PureComponent or lifecycle hook shouldComponentUpdate.

Here is a great link to improve performance, you can apply the same to the context API too

Isaac
  • 8,290
  • 6
  • 32
  • 69
  • 2
    I've already looked at `shouldComponentUpdate`, but it seems like a dead-end. The Provider's value propagates via the Consumer's HoF arguments, while `shouldComponentUpdate` needs access to component props. So I would need to also pass them in both places. Having read through Dan Abramov's tweets on Context, my feeling is that my approach (or my code) here is actually broken in a way I don't see. – Andrew Jul 13 '18 at 04:37
  • 4
    I don't get it, how does this answer the question? React context changes will trigger re-renders irrespectively of `shouldComponentUpdate` (as seen here https://reactjs.org/docs/context.html#contextprovider) – SudoPlz Oct 30 '18 at 10:26