The Gnar Company
The Gnar Company

React Component Comparison

TL;DR

  • Pre 16.8, class components are required for internal state
  • Functional Stateless Components are easier to read (when simple) and alleviate the worry of binding this
  • Disregarding several gotchas, use PureComponent or React.memo when you can (and, in general, make your components depend only on props and state when you can)

What are we doing here?

We're going to compare these things:

  1. Component
  2. PureComponent
  3. Functional Stateless Components (FSCs)
  4. React.memo

in these ways:

  1. Ergonomics
  2. Performance

biased by:

  1. Me
  2. My experiences
  3. Potentially unrealistic scenarios I've concocted to demonstrate or benchmark certain features

Ergonomics

The code

class SimpleClassComponent extends React.Component {
  render() {
    return <div>{this.props.name}</div>
  }
}

class SimplePureComponent extends React.PureComponent {
  render() {
    return <div>{this.props.name}</div>
  }
}

const SimpleFsc = ({ name }) => <div>{name}</div>

const SimpleMemo = React.memo(SimpleFsc)

Component and PureComponent are pretty easy to read and nearly identical (and, pre 16.8, are your only option if you want state) but FSCs are simpler in terms of being able to avoid the this keyword and removing the class boilerplate that comes with an ugly polyfill if you're transpiling to ES5.

Gotchas

PureComponent (or memoized FSCs) and Deeply Mutated Props

Okay this should sound like an obviously bad thing that you should try to avoid. Consider this incredibly contrived example:

class Pure extends React.PureComponent {
  render() {
    return this.props.foo.bar
  }
}

class PureWrapper extends React.Component {
  constructor(props) {
    super(props)
    this.foo = { bar: 1 }
  }

  render() {
    this.foo.bar += 1
    return <Pure foo={this.foo} />
  }
}

In this case, Pure will not rerender because the prop foo hasn't changed (it's the same object in memory). This example is quite ridiculous, but you can run into this with something like Redux if you mutate your global state.

To avoid this, the React documentation suggests:

React.PureComponent’s shouldComponentUpdate() only shallowly compares the objects. If these contain complex data structures, it may produce false-negatives for deeper differences. Only extend PureComponent when you expect to have simple props and state, or use forceUpdate() when you know deep data structures have changed. Or, consider using immutable objects to facilitate fast comparisons of nested data.

Furthermore, React.PureComponent’s shouldComponentUpdate() skips prop updates for the whole component subtree. Make sure all the children components are also “pure”.

PureComponent (or memoized FSCs) Rendering Children

Having a PureComponent render children "inline" sidesteps any optimizations you would normally expect:

class Pure extends React.PureComponent {
  render() {
    return this.props.children
  }
}

const PureFsc = React.memo(({ children }) => children)

class PureWrapper extends React.Component {
  render() {
    return (
      <>
        <Pure>
          <SomeOtherComponent />
        </Pure>
        <PureFsc>
          <SomeOtherComponent />
        </PureFsc>
      </>
    )
  }
}

Here, <SomeOtherComponent /> is sugar for React.createElement('SomeOtherComponent') which returns a new object every time. So in Pure and PureFsc, the children prop is changing (shallowly) every time!

To avoid this, consider caching the children or creating a new pure component that renders the component and its child.

FSCs with PureComponents as Children

FSCs have a minor gotcha involving rendering PureComponent children. If an FSC is passing a function to its PureComponent children as props, odds are it's recreating that function every render and the pure children have no way of successfully performing a strict equality on props. Consider the following setup:

class Pure extends React.PureComponent {
  render() {
    return this.props.fn()
  }
}

class PureWrapper extends React.Component {
  fn = () => <div>Foo</div>
  render() {
    return <Pure fn={this.fn} />
  }
}

const PureWrapperFsc = () => {
  const fn = () => <div>Foo</div>
  return <Pure fn={fn} />
}

Here, PureWrapper, on each render, is passing the same fn to Pure so Pure, being a PureComponent, will not bother calling its render() function. PureWrapperFsc, on the other hand, creates a new function to pass to Pure every render and so Pure will also render every time.

The easiest way to avoid this problem is to use a class component, but if fn doesn't depend on props, you can define it outside your component:

const fn = () => <div>Foo</div>
const PureWrapperFsc = () => <Pure fn={fn} />

Ergonomic Results

All in all, FSCs are the cleanest solution for simple components but regular React Components can have less unexpected side effects.

Performance

"The real problem is that programmers have spent far too much time worrying about efficiency in the wrong places and at the wrong times; premature optimization is the root of all evil (or at least most of it) in programming." - Donald Knuth

Now that we have that out of the way...

Expectations

The main power of PureComponents is that they will avoid their render() function if they receive the same props and their internal state hasn't changed. Let's check out these following examples.

Here, we're rendering a regular React Component:

impure

Notice the console indicating the component's render() function being called on each click.

Now let's see the same setup with a PureComponent:

pure

Notice how we don't see any logs for subsequent renders? That's because PureComponents implement the shouldComponentUpdate lifecycle hook to shallowly compare their new props and state to their old props and state. Because these haven't changed, shouldComponentUpdate() returns false and render() is not called - the previous DOM markup is instantly returned.

* Note the results are the same for FSCs vs React.memoized FSCs.

So in general, we expect big savings when using a PureComponent or React.memoized FSC and we're either rendering many components, or components whose render() function is slow, or a deep tree of components. Let's see what happens!

The Setup

We're going to follow this repo for comparing components. We can mess with a few things:

  1. How many components we're rendering
  2. How long each component's render function takes
  3. How many props we're passing to each component
  4. If components are receiving the same props on subsequent renders

For the following comparisons:

  1. 1000 components are rendered
  2. "Type" indicates which type of component (listed above) we're rendering
  3. "Complexity" indicates how long each component takes to render. Simple ~ 0.25ms per component. Complex ~ 1.2ms per component (note in the repo this corresponds to the 250th prime for me)
  4. "Prop Count" indicates how many props are passed to each component. Props are simple numbers.
  5. Render times are in ms. We record performance.now as part of the setState that starts the render and then in componentDidMount. They are an average over 10 renders
  6. (mutating) indicates that one prop changes for each rendered component each render
  7. (non-mutating) indicates that components receive the exact same props each render
Type Complexity Prop Count Time (mutating) Time (non-mutating)
Component Simple 1 23 23
Component Simple 1000 620 610
Component Complex 1 670 660
Component Complex 1000 1250 1250
FSC Simple 1 22 21
FSC Simple 1000 610 600
FSC Complex 1 600 600
FSC Complex 1000 1180 1170
Pure Simple 1 22 1.2
Pure Simple 1000 740 740
Pure Complex 1 600 1.2
Pure Complex 1000 1320 740
React.memo Simple 1 22 0.83
React.memo Simple 1000 730 750
React.memo Complex 1 540 1.0
React.memo Complex 1000 1260 740

Results

As expected, the render times for non-pure Components are nearly identical regardless of props mutation. For PureComponent and React.memo, we see huge performance savings when rendering lots of complex components whose props aren't changing. That makes sense - if render() takes a long time and we can skip all that work, we'll see big gains. We also see a slight performance boost when choosing functional components over class components.

The only surprising situation here occurs when passing lots of props. It takes roughly 600ms just to pass 1000 props to 1000 children, never mind shallowly compare them. Given that, we do see a slight performance hit when using PureComponent and React.memo because the work of prop-equality-checking is more than the work of render().

Conclusion

  • If you are seeing performance issues related to unnecessary renders (identified using tools like why-did-you-update) you might get some easy wins with PureComponent and React.memo when properly applied.
  • If your component is always receiving new props, pure component types could waste time doing unnecessary prop equality checks, but it likely won't be noticeable.