Why is React.forwardRef preventing this HOC from rendering when context updates?

Viewed 416

I came across this behavior while working on this epic react lesson, and after re-reading the react docs on both forwardRef and HOCs cannot figure out why this would be happening.

I've cut the epic react lesson down below to demonstrate what I am seeing. Basically, it's an AppProvider component providing access to the global state through context, then the withStateSlice HOC retrieves the correct slice of state and injects it into the state prop of the Cell components.

The weird behavior I'm seeing is when the withStateSlice HOC calls forwardRef on the wrapped component:

return React.memo(React.forwardRef(Wrapper))

the profiler shows that only the one Cell that was clicked actually rendered.

Profile With React.forwardRef

If I remove forwardRef from the above so we are left with this:

return React.memo(Wrapper)

then I am seeing all the Cell HOC components rendering, which from what I understand of context should be the expected behavior.

Profile Without React.forwardRef

From what I've read this it doesn't seem like forwardRef should affect rendering when context changes but I could be wrong. This is with React 17.0.2 and I am seeing the same behavior in development and production builds (with profiling turned on using: react-scripts build --profile

import * as React from 'react'

const AppStateContext = React.createContext()
const AppDispatchContext = React.createContext()

function appReducer(state, action) {
  switch (action.type) {
    case 'UPDATE_CELL': {
      const newState = [...state];
      newState[action.i] = state[action.i] + 1;
      return newState 
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function AppProvider({children}) {
  const [state, dispatch] = React.useReducer(appReducer, [5, 5, 5, 5, 5, 5])
  return (
    <AppStateContext.Provider value={state}>
      <AppDispatchContext.Provider value={dispatch}>
        {children}
      </AppDispatchContext.Provider>
    </AppStateContext.Provider>
  )
}

function useAppState() {
  const context = React.useContext(AppStateContext)
  if (!context) {
    throw new Error('useAppState must be used within the AppProvider')
  }
  return context
}

function useAppDispatch() {
  const context = React.useContext(AppDispatchContext)
  if (!context) {
    throw new Error('useAppDispatch must be used within the AppProvider')
  }
  return context
}

function fibonacci(n) {
  return n < 1 ? 0
       : n <= 2 ? 1
       : fibonacci(n - 1) + fibonacci(n - 2)
}

function withStateSlice(Comp, slice) {
  const MemoComp = React.memo(Comp)
  function Wrapper(props, ref) {
    const state = useAppState()
    return <MemoComp ref={ref} state={slice(state, props)} {...props} />
  }
  Wrapper.displayName = `withStateSlice(${Comp.displayName || Comp.name})`
  return React.memo(React.forwardRef(Wrapper)) // return React.memo(Wrapper)
}

function Cell({state: cellValue, i}) {
  const dispatch = useAppDispatch()
  const handleClick = () => dispatch({type: 'UPDATE_CELL', i})

  // Artificial slowdown for better visibility in profiler
  for (let n = 10; n < 20; n++) {
    fibonacci(n);
  }

  return (
    <button
      className="cell"
      onClick={handleClick}
    >
      {Math.floor(cellValue)}
    </button>
  )
}
Cell = withStateSlice(Cell, (state, { i }) => state[i])

function App() {
  return (
    <div className="grid-app">
      <div>
        <AppProvider>
          <Cell i={0} />
          <Cell i={1} />
          <Cell i={2} />
          <Cell i={3} />
          <Cell i={4} />
          <Cell i={5} />
        </AppProvider>
      </div>
    </div>
  )
}

export default App
1 Answers

1. Problem

It's like a bug in devtools when you put React.forwardRef inside React.memo. Therefore, to know whether these Cells are really re-render or not, you can put console.log() inside Wrapper and then click Cell.

2. Some topics about this

I get some details about this topic but it still has not been solved.

Related