[{(IDENTITY CRISIS)}]

Dealing with React Render Cascades

What is a Render Cascade?

Render

Update Props/State

Receive new Props/State

Recalculate Virtual DOM

Flush changes with new prop identities

This is Bad.

const Counter = class extends React.Component {
  componentDidUpdate () {
    this._updateCount(this.props.count);
  }

  _decrement () {
    this._updateCount(this.props.count - 1);
  }

  _increment () {
    this._updateCount(this.props.count + 1);
  }

  _updateCount (count) {
    this.props.onUpdate(count);
  }

  render () {
    return (
      <React.Fragment>
        <div>
          <button type="button" onClick={() => this._increment()}> + </button>
          <button type="button" onClick={() => this._decrement()}> - </button>
        </div>
        <div>{this.props.count}</div>
      </React.Fragment>
    );
  }
};

const CounterManager = class extends React.Component {
  constructor (props) {
    super(props);
    this.state = {
      count: 0,
    };
  }

  render () {
    return (
      <Counter
        count={this.state.count}
        onUpdate={count => this.setState({ count })}
      />
    );
  }
};
  • componentDidUpdate isn't checking what prop updated.
  • Counter buttons are passing inline arrow functions during render.
  • CounterManager is passing an inline arrow function to Counter during render.

This is Better.

const Counter = class extends React.Component {
  constructor (props) {
    super(props);
    this._decrement = this._decrement.bind(this);
    this._increment = this._increment.bind(this);
  }

  componentDidUpdate (prevProps) {
    if (prevProps.onUpdate !== this.props.onUpdate) {
      this._updateCount(this.props.count);
    }
  }

  _decrement () {
    this._updateCount(this.props.count - 1);
  }

  _increment () {
    this._updateCount(this.props.count + 1);
  }

  _updateCount (count) {
    this.props.onUpdate(count);
  }

  render () {
    return (
      <React.Fragment>
        <div>
          <button type="button" onClick={this._increment}> + </button>
          <button type="button" onClick={this._decrement}> - </button>
        </div>
        <div>{this.props.count}</div>
      </React.Fragment>
    );
  }
};

const CounterManager = class extends React.Component {
  constructor (...args) {
    super(...args);
    this._onUpdate = this._onUpdate.bind(this);
    this.state = {
      count: 0,
    };
  }

  _onUpdate (count) {
    this.setState({ count });
  }

  render () {
    return (
      <Counter
        count={this.state.count}
        onUpdate={this._onUpdate}
      />
    );
  }
};
  • componentDidUpdate is checking that onUpdate prop is what updated.
  • _increment and _decrement functions are scope-bound in Counter constructor.
  • _onUpdate function is scope-bound in CounterManager constructor.
  • Constructor signature future-proofed.

This is Best.

const Counter = class extends React.Component {
  componentDidUpdate () {
    if (prevProps.onUpdate !== this.props.onUpdate) {
      this._updateCount(this.props.count);
    }
  }

  _decrement = () => {
    this._updateCount(this.props.count - 1);
  };

  _increment = () => {
    this._updateCount(this.props.count + 1);
  };

  _updateCount (count) {
    this.props.onUpdate(count);
  }

  render () {
    return (
      <React.Fragment>
        <div>
          <button type="button" onClick={this._increment}> + </button>
          <button type="button" onClick={this._decrement}> - </button>
        </div>
        <div>{this.props.count}</div>
      </React.Fragment>
    );
  }
};

const CounterManager = class extends React.Component {
  state = {
    count: 0,
  };

  _onUpdate = (count) => {
    this.setState({ count });
  };

  render () {
    return (
      <Counter
        count={this.state.count}
        onUpdate={this._onUpdate}
      />
    );
  }
};
  • Self-bound functions moved out of constructor to inline arrow-based class methods.
  • State moved out of constructor to class member property.
  • Constructor function removed from class declaration entirely.

React Fiber Optimizations

With React 16 came its new fiber architecture. This allows it to be able to recycle work fibers from previous virtual DOM calculations through memoization, prioritize certain types of workloads over others (e.g. user interactions over animations), or abandon fiber workloads altogether if they become unviable (e.g. canceling a state update if another comes in before processing has finished).

Because of these optimizations, particularly with regards to memoization, certain prop types need to be handled carefully in order to prevent unnecessary virtual DOM calculations due to referential integrity.

Problem - Inline Style Objects

const MyStyledComponent = () => (
  <SubComponent style={{ padding: '10px' }} />
);

const MyDynamicallyStyledComponent = ({ padding }) => (
  <SubComponent style={{ padding }} />
);

Inline style objects will generate new references with each render cycle. React will read this as a prop change when doing a shallow comparison of props, and will consider this to be a fresh prop that needs to recalculate the state of its virtual DOM on every single render cycle.

Solution - Inline Style Objects

const styles = {
  padding: '10px',
};

const MyStyledComponent = () => (
  <SubComponent style={styles} />
);

For static style object definitions, move the object definition outside of render, to the top of the module, so it maintains referential integrity across render cycles.

Solution - Inline Style Objects

import { createSelector } from 'reselect';

const styleSelector = createSelector(
  padding => padding,
  padding => ({ padding })
);

const MyDynamicStyledComponent = ({ padding }) => (
  <SubComponent style={styleSelector(padding)} />
);

For dynamic style object definitions, use a memoization utility like 'reselect' to allow a consistent object reference to be returned, and to be re-cached whenever it changes.

Problem - Inline Arrow Functions

const MyInteractiveComponent = () => (
  <SubComponent onClick={() => alert('Hello World!')} />
);

const MyDecorativeInteractiveComponent = ({ onClick }) => (
  <SubComponent onClick={e => onClick(e.target.value)} />
);

Inline arrow functions suffer from the same issues as inline style objects, in that they will have a separate reference with every render cycle, and be perceived as a prop change, causing a recalculation of the virtual DOM, even though the same function is being passed.

Solution - Inline Arrow Functions

import { createSelector } from 'reselect';

const onClick = () => alert('Hello World!');
const onClickSelector = createSelector(
  onClick => onClick,
  onClick => e => onClick(e.target.value)
);

const MyInteractiveComponent = () => (
  <SubComponent onClick={onClick} />
);

const MyDecorativeInteractiveComponent = ({ onClick }) => (
  <SubComponent onClick={onClickSelector(onClick)} />
);

Inline arrow functions can be dealt with in the same way that inline objects can. If they are static in nature, they can be moved outside of the component, and otherwise, they can be memoized with a utility like 'reselect'.

Problem - Immutable FP/Copies

const MyFilteredDataComponent = ({ data, filterFn }) => {
  const filteredData = data.filter(filterFn);
  return <SubComponent data={filteredData} />
};

const MyTransformedDataComponent = ({ data }) => {
  const transformedData = data.map(item => ({
    ...item,
    id: item.name,
  }));
  return <SubComponent data={transformedData} />
};

Functional-style programming paradigms have immutable returns, meaning they generate new references by default, and spread syntax generates object copies. These types of programming patterns during the render cycle can lead to referential integrity issues, which can lead to virtual DOM recalculations.

Solution - Immutable FP/Copies

import { createSelector } from 'reselect';

const filteredDataSelector = createSelector(
  data => data,
  (_, filterFn) => filterFn,
  (data, filterFn) => data.filter(filterFn);
);

const MyFilteredDataComponent = ({ data, filterFn }) => {
  const filteredData = filteredDataSelector(data, filterFn);
  return <SubComponent data={filteredData} />
};

As with previous examples, a utility like 'reselect' can be used to provide a memoization strategy, which will return a consistent reference for the output array. It has the added benefit of only performing potentially expensive calculations once per memoization cycle, and returning that cached output on subsequent calls.

Solution - Immutable FP/Copies

import { createSelector } from 'reselect';
import createCachedSelector from 're-reselect';

const itemSelector = createCachedSelector(
  item => item,
  item => ({
    ...item,
    id: item.name,
  })
)(item => item.name);
const transformedDataSelector = createSelector(
  data => data,
  data => data.map(itemSelector)
);

const MyTransformedDataComponent = ({ data }) => {
  const transformedData = transformedDataSelector(data);
  return <SubComponent data={transformedData} />
};

Caching strategies can be combined also, to help eliminate unnecessary object copies while also enforcing referential integrity on arrays. The 're-reselect' library can provide a multi-layered approach to memoizing lists of items with unique keys.

Reselect

import { createSelector } from 'reselect';

const normalSelector = createSelector(
  param1 => param1,
  (_, param2) => param2.someProp,
  (_, param2) => param2.someOtherProp,
  (_, __, param3) => !!Object.keys(param3).length,
  (param1, someProp, someOtherProp, param3KeyLength) => {
    return param1(someProp, someOtherProp, param3KeyLength);
  }
);

const foo = {};
const bar = { someProp: true, someOtherProp: false };
const baz = {};
const qux = normalSelector(foo, bar, baz);
const quux = normalSelector(foo, bar, baz);
const quuz = normalSelector(foo, {}, baz);
const corge = normalSelector(foo, bar, baz);
console.log(qux === quux); // true
console.log(quux === quuz); // false
console.log(quux === corge); // false

Reselect let you to set up a memoized output response for a specific set of input parameters. Each input is passed through the callbacks, and returns are evaluated strictly. If anything differs, the cache is recreated from the new output value.

Re-reselect

import createCachedSelector from 're-reselect';

const multiSelector = createCachedSelector(
  param1 => param1,
  (_, param2) => param2,
  (param1, param2) => {
    return param1(param2);
  }
)((_, param2) => param2.someKey); // <-- Dat magic, doe.

const foo = {};
const bar = { someKey: uuid.v4() };
const bar = normalSelector(foo, bar);
const qux = normalSelector(foo, bar);
const quux = normalSelector(foo, { someKey: uuid.v4() });
const quuz = normalSelector(foo, bar);
console.log(bar === qux); // true
console.log(qux === quux); // false
console.log(qux === quuz); // true

Re-reselect lets you set up N memoized output responses for different sets of input parameters. Each set is keyed off of the chained function call, which should return a unique value, and each selector is governed by the same rules of reselect.

Final Thoughts

With a few simple changes to React authoring style, a lot of unnecessary virtual DOM recalculations can be prevented, leading to a much more performant application. Runaway render cascades can cause components to reevaluate their view state literally hundreds of times in some cases, which makes the UI sluggish or totally unresponsive, especially when dealing with deep component trees.

fn.

IDENTITY CRISIS

By Richard Lindsey