bfricka
bfricka

Reputation: 329

useMemo vs useState for React hooks constants

Defining a calculated (initialized) constant using React hooks can be performed in two ways that seem functionally equivalent. I don't want to get into the use cases for this, but suffice to say that there are cases where a constant value can be derived from initial props or state that isn't expected to change (think route data, bound dispatch, etc).

First, useState

const [calculatedConstant] = useState(calculateConstantFactory);

Second, useMemo

const calculatedConstant = useMemo(calculateConstantFactory, []);

Both of these seem functionally equivalent, but without reading the source code, I'm not sure which is better in terms of performance or other considerations.

Has anyone done the leg work on this? Which would you use and why?

Also, I know some people will recoil at the assumption that state can be "considered constant". I'm not sure what to tell you there. But even without state, I may want to define a constant within a component that has no state at all, for example, creating a block of JSX that doesn't change.

I could define this outside of the component, but then it's consuming memory, even when the component in question is not instantiated anywhere in the app. To fix this, I would have to create a memoization function and then manually release the internal memoized state. That's an awful lot of hassle for something hooks give us for free.

Edit: Added examples of the approaches talked about in this discussion. https://codesandbox.io/s/cranky-platform-2b15l

Upvotes: 20

Views: 7859

Answers (2)

gentlee
gentlee

Reputation: 3717

I wrote a benchmark to measure the performance of all options, and the results are (lower is better):

  • useRef (16ms) - best
  • useMemo (17.6ms)
  • useState (23.8ms)
// useRef
const stateRef = React.useRef(null)
if (stateRef.current === null) {
  stateRef.current = calculateConstantFactory()
}

// as a hook
export const useConstant = (initializer) => {
  const constantRef = useRef(null);
  if (constantRef.current === null) {
    constantRef.current = initializer();
  }
  return constantRef.current
}

But:

  • Difference is tiny.
  • Keep in mind that useMemo returns a cache that theoretically can be re-evaluated at any time.

Benchmark (better to save to html and run in browser):

<html>
  <header>
    <script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
  </header>
  <body>
    <div id="root" />
    <script>
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))

document.addEventListener('DOMContentLoaded', async () => {
  const emptyArray = []

  const work = () => {
    return Math.random()
  } 

  const components = {
    useMemo: function UseMemo() {
      const start = performance.now()

      const state = React.useMemo(() => {
        return work()
      }, [])

      const end = performance.now()
      localStorage.setItem("useMemo", +localStorage.getItem("useMemo") + (end - start))
      return null
    },
    useMemoConstArray: function UseMemoConstArray() {
      const start = performance.now()

      const state = React.useMemo(() => {
        return work()
      }, emptyArray)

      const end = performance.now()
      localStorage.setItem("useMemoConstArray", +localStorage.getItem("useMemoConstArray") + (end - start))
      return null
    },
    useState: function UseState() {
      const start = performance.now()

      const [state] = React.useState(() => {
        return work()
      })

      const end = performance.now()
      localStorage.setItem("useState", +localStorage.getItem("useState") + (end - start))
      return null
    },
    useRef: function UseRef() {
      const start = performance.now()

      const stateRef = React.useRef(-1)
      if (stateRef.current === -1) {
        stateRef.current = work()
      }

      const end = performance.now()
      localStorage.setItem("useRef", +localStorage.getItem("useRef") + (end - start))
      return null
    }
  }

  const componentIndex = +localStorage.getItem("componentIndex") || 0
  const componentsCount = Object.keys(components).length
  const componentKey = Object.keys(components)[componentIndex]
  const component = components[componentKey]
  const element = React.createElement(component)
  const iterations = 10000

  const root = ReactDOM.createRoot(document.getElementById('root'))
  
  if (componentIndex === 0) {
    localStorage.clear()
  }

  // Mount each

  for (let i = 1; i <= iterations; i += 1) {
    root.render(React.createElement(component, {key: i}))
    i % 1000 === 0 && console.log(`${componentIndex + 1}/${componentsCount}:${componentKey} mount each: ${i}`)
    await delay(0)
  }

  // Move keys

  localStorage.setItem(componentKey + "-mount-each", localStorage.getItem(componentKey))
  localStorage.removeItem(componentKey, 0)

  // Mount once

  for (let i = 1; i <= iterations; i += 1) {
    root.render(React.createElement(component))
    i % 1000 === 0 && console.log(`${componentIndex + 1}/${componentsCount}:${componentKey} mount once: ${i}`)
    await delay(0)
  }

  // Go to next || print results

  if (componentIndex < componentsCount - 1) {
    localStorage.setItem("componentIndex", componentIndex + 1)
    window.location.reload()
  } else {
    console.log('Mount each:')
    console.table(Object.keys(components).reduce((result, key) => {
      result[key] = localStorage.getItem(key + "-mount-each")
      return result
    }, {}))

    console.log('Mount once:')
    console.table(Object.keys(components).reduce((result, key) => {
      result[key] = localStorage.getItem(key)
      return result
    }, {}))
    localStorage.clear()
  }
})
    </script>
  </body>
</html>

Upvotes: 1

Raicuparta
Raicuparta

Reputation: 2115

You may rely on useMemo as a performance optimization, not as a semantic guarantee

Meaning, semantically useMemo is not the correct approach; you are using it for the wrong reason. So even though it works as intended now, you are using it incorrectly and it could lead to unpredictable behavior in the future.

useState is only the correct choice if you don't want your rendering to be blocked while the value is being computed.

If the value isn't needed in the first render of the component, you could use useRef and useEffect together:

const calculatedConstant = useRef(null);

useEffect(() => {
  calculatedConstant.current = calculateConstantFactory()
}, [])

// use the value in calcaulatedConstant.current

This is the same as initializing an instance field in componentDidMount. And it doesn't block your layout / paint while the factory function is being run. Performance-wise, I doubt any benchmark would show a significant difference.

The problem is that after initializing the ref, the component won't update to reflect this value (which is the whole purpose of a ref).

If you absolutely need the value to be used on the component's first render, you can do this:

const calculatedConstant = useRef(null);

if (!calculatedConstant.current) {
  calculatedConstant.current = calculateConstantFactory();
}
// use the value in calculatedConstant.current;

This one will block your component from rendering before the value is set up.

If you don't want rendering to be blocked, you need useState together with useEffect:

const [calculated, setCalculated] = useState();

useEffect(() => {
  setCalculated(calculateConstantFactory())
}, [])

// use the value in calculated

Basically if you need the component to re-render itself: use state. If that's not necessary, use a ref.

Upvotes: 11

Related Questions