Just as tuple&record into stage2, just put half a year of draft update a wave.

For complex React single-page applications, performance issues and UI consistency issues must be considered. These two issues are closely related to the React re-rendering mechanism. This article focuses on controlling rerendering to address performance and UI consistency issues in React applications.

Default rendering behavior

React triggers a page update in two phases

Render: mainly responsible for the DIff calculation of vDOM

Commit Phase: Is responsible for updating the results of the VDOM diff to the actual DOM.

Rendering is divided into the first rendering and the second rendering. The first rendering is the first rendering, which is inevitable without further discussion. The second rendering refers to the subsequent rendering process caused by the change of state, props and other factors. This is critical to the performance of our application and the consistency of our page UI, and is the focus of our discussion.

One of React’s most important (and most reviled) features about rendering is this

When a parent component rerenders, it recursively rerenders all child components by default

When a parent component rerenders, it recursively rerenders all child components by default

When a parent component rerenders, it recursively rerenders all child components by default

In the example below, although the props of our Child component did not change, because the Parent triggered the re-render, it also triggered the re-render of the Child components

import * as React from "react"
function Parent() {
  const [count, setCount] = React.useState(0)
  const [name, setName] = React.useState("")
  React.useEffect(() => {
    setInterval(() => {
      setCount(x => x + 1)
    }, 1000)
  }, [])
  return (
    <>
      <input
        value={name}
        onChange={e => {
          setName(e.target.value)
        }}
      />
      <div>counter:{count}</div>
      <Child name={name} />
    </>
  )
}
function Child(props: { name: string }) {
  console.log("child render", props.name)
  return <div>name:{props.name}</div>
}
export default function App() {
  return <Parent />
}
Copy the code

React doesn’t really care if your props change, just perform a global refresh. If the props of all components are not changed, even though React performs a global calculation and does not generate any VDOM diff, there will be no DOM updates during commmit and you will not feel any UI updates. But it still wastes a lot of time doing render calculations, which can sometimes be a performance bottleneck for large React applications. Let’s try to optimize it.

Shallow comparison optimization

React actually provides three apis for shouldComponentUpdate to help address the above performance issues: if you return false during this lifecycle, you can skip the subsequent render process for the component

React.PureComponent: shallow comparisons are made to the props of the Component passed in. If shallow comparisons are equal, the render process is skipped and applies to Class Component *

React.memo: Same as above, for functional Component

Here we define reference equality, value equality, shallow equality and deep equality. C# Equality word order

Primitive value and Object. Primitive value includes Undefined, Null, Boolean, Number, String, The biggest difference between primitive and Object is that the primitive is a Symbol

  • Primtive is immutable, whereas object is generally possible

  • The Primitive comparison does a value comparison and the object comparison does a reference comparison

1 === 1 // true
{a:1} === {a:1} // false
const arr = [{a:2}]
const a = arr[0];
const b = arr[0];
a === b // true
Copy the code

We found that for the above object, even if the values of each attribute are exactly equal, === still returns false because it does not compare values by default. For objects, there are not only reference comparisons, but also deep and shallow comparisons

Const x = {a:1} const y = {a:1} x === y // false reference unequal shallowEqual(x,y) // true The first level attributes of each object are equal deepEqual(x,y) // true Const a = {x: {x:1}, y:2} const b = {x: {x:1}, y:2} a === b // reference unequal shallowEqual(x,y) // false a.x ==== b.x result is false, DeepEqual (x,y) // true A.X.X === b.x.x&&a.y === B.y, deep = const state1 = {items: [] {x: 1}} / / time point 1 state1. Items. Push ([2} {x:]) / 2 / time pointCopy the code

It is found that although the value of state1 changes from time point 1 to time point 2, its reference does not. That is, deepEqual at time point 1 and time point 2 actually changes, but their reference does not.

We find that the results of deep object comparisons often conflict with the results of shallow object comparisons and object reference comparisons, which is actually the source of many front-end problems. Deep comparison equality is more in line with what we understand to mean by value equality (as opposed to reference equality) of objects (we will no longer distinguish between deep comparison equality and value equality of objects).

In fact, many of the problems with React and hooks are rooted in inconsistencies between object reference comparisons and deep object comparisons, i.e

When object values remain unchanged, object references change, which invalidates the cache of the React component, resulting in performance problems

When object values change, object references do not change, resulting in UI and data inconsistency of the React component

For the general MVVM framework, the framework is mostly responsible for helping to handle the consistency of ViewModel <=> Views, i.e

When the ViewModel changes, the View can refresh with it

When the ViewModel doesn’t change, the View doesn’t change

Our ViewModel usually includes primitive values as well as Object values. For most UIs, the UI itself doesn’t care about the object reference. Instead, it cares about the object value (the value of each leaf node property and the topological relationship of the node). Because it actually maps the object’s value to the actual UI, there is no direct feedback on the UI about the object’s reference.

The react. memo ensures that the component will be re-rendered only when the props changes (and the internal state and context changes will also be re-rendered). We need to package the component so that the Child component can be re-rendered as long as the props remain the same. No rerendering is triggered

import * as React from "react" function Parent() { const [count, setCount] = React.useState(0) const [name, setName] = React.useState("") React.useEffect(() => { setInterval(() => { setCount(x => x + 1) }, 1000) }, []) return ( <> <input value={name} onChange={e => { setName(e.target.value) }} /> <div>counter:{count}</div> <Child Const Child = react. memo(function Child(props: {name: _)) {function Child(props: {name: _)} string }) { console.log("child render", props.name) return <div>name:{props.name}</div> }) export default function App() { return <Parent /> }Copy the code

If our props only contained primitive types (string, number, etc.), then the react. memo would be sufficient, but if our props contained objects, it wouldn’t be that simple. We continued to add a new Item props to our Child component, when the props became Object, and the problem arose when the Child component was rerendered even though we felt that our object had not changed.

import * as React from "react"
interface Item {
  text: string
  done: boolean
}

function Parent() {
  const [count, setCount] = React.useState(0)
  const [name, setName] = React.useState("")
  console.log("render Parent")
  const item = {
    text: name,
    done: false,
  }
  React.useEffect(() => {
    setInterval(() => {
      setCount(x => x + 1)
    }, 5000)
  }, [])
  return (
    <fragment>
      <input
        value={name}
        onChange={e => {
          setName(e.target.value)
        }}
      ></input>
      <div>counter:{count}</div>
      <Child item={item} />
    </fratment>
  )
}
const Child = React.memo(function Child(props: { item: Item }) {
  console.log("render child")
  const { item } = props;
  return <div>name:{item.text}</div>
})
export default function App() {
  return <Parent />
}
Copy the code

The react. memo comparison uses a shallow comparison, and the child accepts a new literal object each time. The comparison of each literal object is a reference comparison. Although their individual attributes may have equal values, the comparison result is still false, further causing shallow comparisons to return false, causing the Child component to still be rerendered

const obj1 = {
  name: "yj",
  done: true,
}
const obj2 = {
  name: "yj",
  done: true,
}
obj1 === obj2 // false
Copy the code

For our reference, the result of our final rendering actually depends on the value of each leaf node of the object, so our natural expectation is not to trigger rerendering if the value of the leaf node is constant, i.e. the object’s deep comparison results are consistent.

There are two ways to solve this,

  • The first, of course, is to make a direct deep comparison rather than a shallow one

  • The second way is to ensure that the results of shallow comparison are equal if the results of Item deep comparison are equal

Luckily, the react. memo accepts the second argument, which is used to customize control how properties are compared to each other. Modify the child component as follows

const Child = React.memo( function Child(props: { item: Item }) { console.log("render child") const { item } = props return <div>name:{item.text}</div> }, (prev, Return deepEqual(prev, next)})Copy the code

While this can be done, there is still a high performance overhead or even risk of death when dealing with complex objects (such as dealing with circular references), so using deep comparisons for performance optimization is not recommended.

The second way is to make sure that if the values of the objects are equal, we make sure that the references to the generated objects are equal, which usually falls into two categories

If the object itself is a fixed constant, you can use useRef to ensure that the object references are equal each time you access them

function Parent() { const [count, setCount] = React.useState(0) const [name, setName] = React.useState("") React.useEffect(() => { setInterval(() => { setCount(x => x + 1) }, 1000) }, []) const item = React.useRef({ text: name, done: false, Return (<> <input value={name} onChange={e => {setName(e.target.value)}} /> <div>counter:{count}</div> <Child item={item.current} /> </> ) }Copy the code

The problem is also obvious, if our name changes, our item will still use the old value and will not be updated, resulting in our child components will not trigger rerender, resulting in data and UI inconsistencies, which is worse than the repeat render problem. So useRef can only be used with constants. Microsoft’s Fabric UI encapsulates this pattern by encapsulating a useConst to prevent constant references from changing between render.

So how do we make sure that item is the same as last time when name stays the same, and is different from last time when name changes? useMemo!

UseMemo guarantees that while its dependency does not change, the objects generated by its dependency do too (embarrassingly, because of cache busting) by modifying the code below

function Parent() { const [count, setCount] = React.useState(0) const [name, setName] = React.useState("") React.useEffect(() => { setInterval(() => { setCount(x => x + 1) }, 1000) }, []) const item = React.useMemo( () => ({ text: name, done: }), [name]) // If name does not change, Return (<> <input value={name} onChange={e => {setName(e.target.value)}} /> <div>counter:{count}</div> <Child item={item} /> </> ) }Copy the code

This ensures that changes in state or props other than name in the Parent component do not regenerate new items. This ensures that the Child component does not re-render when props does not change.

But it didn’t stop there

As we continue to extend our application, one Parent may contain more than one Child

function Parent() {
  const [count, setCount] = React.useState(0)
  const [name, setName] = React.useState("")
  const [items, setItems] = React.useState([] as Item[])
  React.useEffect(() => {
    setInterval(() => {
      setCount(x => x + 1)
    }, 1000)
  }, [])
  const handleAdd = () => {
    setItems(items => {
      items.push({
        text: name,
        done: false,
        id: uuid(),
      })
      return items
    })
  }
  return (
    <form onSubmit={handleAdd}>
      <Row>counter:{count}</Row>
      <Row>
        <Input
          width={50}
          size="small"
          value={name}
          onChange={e => {
            setName(e.target.value)
          }}
        />
        <Button onClick={handleAdd}>+</Button>
        {items.map(x => (
          <Child key={x.id} item={x} />
        ))}
      </Row>
    </form>
  )
}

Copy the code

When we click the Add button, we find that the list below is not refreshed until the next time we type. The problem is that the setState operation returned by useState is significantly different from the setState operation in the class component.

  • Class setState: Whatever state you pass in will force the current component to refresh
  • SetState from hooks: Components are not refreshed if two state references are equal. Therefore, users are required to ensure that shallow comparisons are not equal if deep comparisons are not. Otherwise, views and UI are inconsistent.

This change in hooks means that if you modify an object in a component, you must also ensure that the modified object references are not the same as the original object (which was a requirement of the reducers in Redux, not the setState of the class). Modify the above code as follows

function Parent() { const [count, setCount] = React.useState(0) const [name, setName] = React.useState("") const [items, setItems] = React.useState([] as Item[]) React.useEffect(() => { setInterval(() => { setCount(x => x + 1) }, 1000) }, []) const handleAdd = () => { setItems(items => { const newItems = [ ...items, { text: name, done: false, id: Uuid (),},] // Ensure that new items are generated each time, Return (<form onSubmit={handleAdd}> <Row>counter:{count}</Row> <Row> <Input width={50}) size="small" value={name} onChange={e => { setName(e.target.value) }} /> <Button onClick={handleAdd}>+</Button> {items.map(x => ( <Child key={x.id} item={x} /> ))} </Row> </form> ) }Copy the code

This actually requires us not to update the old state directly, but to keep the old state unchanged and generate a new state, which means that state should be an IMmutable object. Making an IMmutable update for the items above doesn’t seem complicated, but it’s not so easy to make an immutable update for more complex objects

const state = [{name: 'this is good', done: false, article: { title: 'this is a good blog', id: 5678 }},{name: 'this is good', done: false, article:{ title: 'this is a good blog', id: 1234}}] state[0]. Artile title = 'new article' // Const newState = [{{...state[0], article: { ...state[0].article, title: 'new article' } }, ...state }]Copy the code

We find the immutable update more troublesome and difficult to understand than the direct mutable one. Our code is riddled with… Operation, we can call it spread hell(yes, another hell). This is clearly not what we want.

deep clone is bad

Our needs are very simple

  • One is the need to change state
  • Second, the state to be changed is non-referential equal to the previous state

Make a deep copy and then modify it to mutable

const state = [ { name: "this is good", done: false, article: { title: "this is a good blog", id: 5678, }, }, { name: "this is good", done: false, article: { title: "this is a good blog", id: 1234,},},] const newState = deepCone(state) state[0]. Artile title = "new article"Copy the code

The two obvious disadvantages of deep copy are the performance of copy and the handling of circular references. However, even though there are some libraries that support high performance copy, there is still a fatal flaw that breaks the Reference Equality and invalidates the entire cache strategy of React. Consider the following code

const a = [{ a: 1 }, { content: { title: 2 } }]
const b = lodash.cloneDeep(a)
a === b // false
a[0] === b[0] // false
a[1].content === b[0].content // false

Copy the code

We found that the reference equality of all objects was broken, which meant that all components containing the objects mentioned above in props would trigger meaningless rerenders even if the properties in the object were unchanged, which could lead to serious performance problems. In React, there are only a few requirements for updating state. OldState, a complex object, can be treated as an attribute tree if there is no circular reference. If we want to change the attributes of a node and return a new object, newState is required

  • References to this node and its component nodes are not equal in the old and new states: to ensure that the component UI state occurring in the props can be refreshed, that is, to keep the Model and view consistent
  • References that are not to this node and its ancestors are kept equal in the old and new states: components that are guaranteed to remain props, and therefore, components that are guaranteed to remain props, are not refreshed, that is, the component’s cache is not invalidated

Unfortunately, Javascript doesn’t have built-in support for Immutable data, let alone for Immutable data updates, but with the help of third-party libraries like Immer and imMutableJS, This simplifies handling immutable data updates.

import { produce } from 'immer'; const handleAdd = () => { setItems( produce(items => { items.push({ text: name, done: false, id: uuid() }); })); };Copy the code

They are structing shared to ensure that only references to modified child states are updated and references to unmodified child states are not modified to ensure that the cache of the entire component tree does not fail.

React fixes the rerender problem

React: How does React solve the rerender problem

  • By default, the React component refresh causes all its children to refresh recursively
  • Use the React. Memo shallowEqual to shallow compare the props to ensure that the component will not be refreshed if the props remain the same
  • Shallow comparisons are only guaranteed to work for Primitive and may cause references to change even if the value of an object is unchanged
  • By introducing useRef and useMemo, React Hook setState only triggers rerender if state changes before and after. This requires that if we change the value of state, we do not need to ensure that the reference to state is changed
  • We cannot modify the state by deep copy, because references to the child states of the entire state that have not changed will also change, invalidating all caches
  • This requires that state() be updated immutable to update references and ensure caching.
  • We can simplify writing immutable state updates by using third-party libraries such as immer.

immutable record & tuple

So far, we find that the root cause of the react strategy is the inconsistency between object value comparison and reference comparison. If the two are consistent, there is no need to worry about reference changes when object values remain unchanged, or reference changes when objects only change. If an object has a built-in immutable update mode, there is no need to reference a third-party library to simplify the update operation.

The record & Tuple object literal comparison is based on a value comparison

> #{x: 1, y: 4} === #{x: 1, y: 4}
true

Copy the code

This avoids we need through useMemo | useRef to guarantee object reference equality

Record and tuple are immutable

const obj = #{a:1} obj.b = 10; // error disallows modifying recordCopy the code

This ensures that when we change the value of record, it will compare differently to the previous value

update

I haven’t seen an elegant built-in way yet

immutable function

Immutable record and tuple can greatly simplify react state synchronization and performance issues, but there is another thing to consider for complex Reac applications: side effects. Most of the side effects are related to functions. The processing of event clicks and the triggering of effect in useEffect cannot be separated from functions, because functions can also be used as props. Therefore, we also need to ensure that the value semantics of the function are consistent with the reference semantics of the function. Otherwise it is still possible to crash the React cache system by passing callback.

function Parent(){
  const [state,setState] = useState();
  const ref = useRef(state);
  useEffect(() => {
    ref.current = state;
  },[state])
  const handleClick = () => {
    console.log('state',state)
    console.log('ref:', ref.current)
  }
  return <Child onClick={handleClick}></Child>
}
const  Child = React.memo((props: {onCilck}) => {
  return <div onClick={props.onClick}>
})
Copy the code

We found that a new handleClick is generated each time the parent component is rerendered, even though the generated functions all have the same function (value semantics). React introduced the useCallback to ensure that references were equal without changing functions

const handleClick = useCallback(handleClick, ['state'])
Copy the code

If you reference an external free variable in a function, write it in a useCallback dependency if it is immutable for the current snapshot because

Const handleClick = () => {console.log('state:',1)} and const handleClick = () => {console.log('state:',2)}Copy the code

Different value semantics are expressed, so its reference comparison should change as state changes.

We can even go one step further and pretend that the following grammatical sugar exists

const handleClick = #(() => {
  console.log('state:',state)
})
Copy the code

With compiler tools such as babel-plugin-auto-add-use-callback(hypothetical compiler optimizer, as Dan often talks about), this can be automatically translated into the following code

const handleClick = useCallback(()=> {
  console.log('state:', state);
},[state])
Copy the code

In this way, we can ensure the consistency of the value semantics and reference semantics of functions, which is the final solution of solution 3 and solution 7 in useCallback. In react, data and function strictly match the value semantics and reference semantics. This also addresses stale closures and infinite loops

  • Stale closures: All callback functions are wrapped with #(callback), which automatically converts them to useCallback and writes to the internal closure dependencies. This avoids stale references in closures that are not written, and ensures that references change only when the value semantics change, avoiding cache invalidation.
  • Infinite loop: fetchData and Query only cause reference changes if semantics change, avoiding the problem of useEffect being triggered unintentionally.

React-hooks are designed to be immutable, because javascript objects and functions are not by default immutable. This is much more acceptable in a language that supports immutable by default.