Andrew
Andrew

Reputation: 14526

React Context API and avoiding re-renders

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.

Upvotes: 24

Views: 21502

Answers (3)

Tudor Morar
Tudor Morar

Reputation: 3868

I made a proof of concept on how to benefit from React.Context, but avoid re-rendering children that consume the context object. The solution makes use of React.useRef and CustomEvent. Whenever you change count or lang, only the component consuming the specific proprety gets updated.

Check it out below, or try the CodeSandbox

index.tsx

import * as React from 'react'
import {render} from 'react-dom'
import {CountProvider, useDispatch, useState} from './count-context'

function useConsume(prop: 'lang' | 'count') {
  const contextState = useState()
  const [state, setState] = React.useState(contextState[prop])

  const listener = (e: CustomEvent) => {
    if (e.detail && prop in e.detail) {
      setState(e.detail[prop])
    }
  }

  React.useEffect(() => {
    document.addEventListener('update', listener)
    return () => {
      document.removeEventListener('update', listener)
    }
  }, [state])

  return state
}

function CountDisplay() {
  const count = useConsume('count')
  console.log('CountDisplay()', count)

  return (
    <div>
      {`The current count is ${count}`}
      <br />
    </div>
  )
}

function LangDisplay() {
  const lang = useConsume('lang')

  console.log('LangDisplay()', lang)

  return <div>{`The lang count is ${lang}`}</div>
}

function Counter() {
  const dispatch = useDispatch()
  return (
    <button onClick={() => dispatch({type: 'increment'})}>
      Increment count
    </button>
  )
}

function ChangeLang() {
  const dispatch = useDispatch()
  return <button onClick={() => dispatch({type: 'switch'})}>Switch</button>
}

function App() {
  return (
    <CountProvider>
      <CountDisplay />
      <LangDisplay />
      <Counter />
      <ChangeLang />
    </CountProvider>
  )
}

const rootElement = document.getElementById('root')
render(<App />, rootElement)

count-context.tsx

import * as React from 'react'

type Action = {type: 'increment'} | {type: 'decrement'} | {type: 'switch'}
type Dispatch = (action: Action) => void
type State = {count: number; lang: string}
type CountProviderProps = {children: React.ReactNode}

const CountStateContext = React.createContext<State | undefined>(undefined)

const CountDispatchContext = React.createContext<Dispatch | undefined>(
  undefined,
)

function countReducer(state: State, action: Action) {
  switch (action.type) {
    case 'increment': {
      return {...state, count: state.count + 1}
    }
    case 'switch': {
      return {...state, lang: state.lang === 'en' ? 'ro' : 'en'}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function CountProvider({children}: CountProviderProps) {
  const [state, dispatch] = React.useReducer(countReducer, {
    count: 0,
    lang: 'en',
  })
  const stateRef = React.useRef(state)

  React.useEffect(() => {
    const customEvent = new CustomEvent('update', {
      detail: {count: state.count},
    })
    document.dispatchEvent(customEvent)
  }, [state.count])

  React.useEffect(() => {
    const customEvent = new CustomEvent('update', {
      detail: {lang: state.lang},
    })
    document.dispatchEvent(customEvent)
  }, [state.lang])

  return (
    <CountStateContext.Provider value={stateRef.current}>
      <CountDispatchContext.Provider value={dispatch}>
        {children}
      </CountDispatchContext.Provider>
    </CountStateContext.Provider>
  )
}

function useState() {
  const context = React.useContext(CountStateContext)
  if (context === undefined) {
    throw new Error('useCount must be used within a CountProvider')
  }
  return context
}

function useDispatch() {
  const context = React.useContext(CountDispatchContext)
  if (context === undefined) {
    throw new Error('useDispatch must be used within a AccountProvider')
  }
  return context
}

export {CountProvider, useState, useDispatch}

Upvotes: 2

user10645790
user10645790

Reputation: 1274

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.

Upvotes: 18

Isaac
Isaac

Reputation: 12894

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

Upvotes: 1

Related Questions