React Hook + TS shopping cart practice (performance optimization, closure trap, custom hook) - Zhihu

Keywords: React github TypeScript axios

Preface

This article is based on a basic shopping cart demand, step by step, to give you a deep understanding of the pit and Optimization in React Hook

You can learn from this article:

A kind of Practice of writing business components with React Hook + TypeScript

A kind of How to use React.memo to optimize performance

A kind of How to avoid the closure trap brought by Hook

A kind of How to abstract a simple and easy-to-use custom hook

Preview address

https://sl1673495.github.io/react-cart

Code repository

The code involved in this article has been sorted out into github warehouse, and a sample project has been built with cra. For the part of performance optimization, you can open the console to see the re rendering situation.

https://github.com/sl1673495/react-cart

Demand decomposition

As a shopping cart demand, it must involve several demand points: 1. Check, select all and invert selection. 2. Calculate the total price according to the selected items.



Requirement realization

get data

First of all, we request the shopping cart data. This is not the focus of this article. It can be realized through a custom request hook, or through a common useState + useEffect.

const getCart = () => {
  return axios('/api/cart')
}
const { 
  // Cart data
  cartData,
  // How to re request data
  refresh 
} = useRequest<CartResponse>(getCart)

Check logic implementation

We cons id er using an object as a mapping table to record all checked commodity IDS through the variable checkedMap:

type CheckedMap = {
  [id: number]: boolean
}
// Commodity selection
const [checkedMap, setCheckedMap] = useState<CheckedMap>({})
const onCheckedChange: OnCheckedChange = (cartItem, checked) => {
  const { id } = cartItem
  const newCheckedMap = Object.assign({}, checkedMap, {
    [id]: checked,
  })
  setCheckedMap(newCheckedMap)
}

Calculate total check price

Use reduce to realize a function of calculating the sum of prices

// Sum of cartItems
 const sumPrice = (cartItems: CartItem[]) => {
    return cartItems.reduce((sum, cur) => sum + cur.price, 0)
 }

Then you need a function to filter out all the selected products

// Return all selected cartItems
const filterChecked = () => {
  return (
    Object.entries(checkedMap)
      // Filter out all items with checked status of true through this filter
      .filter(entries => Boolean(entries[1]))
      // Then map the selected list from cartData according to the id
      .map(([checkedId]) => cartData.find(({ id }) => id === Number(checkedId)))
  )
}

Finally, combine these two functions and the price will come out:

// Calculate gift points
  const calcPrice = () => {
    return sumPrice(filterChecked())
  }

Some people may wonder why a simple logic should extract such several functions. Here I want to explain that in order to ensure the readability of the article, I have simplified the real needs.

In the real demand, the total price of different types of goods may be calculated separately, so the filterChecked function is indispensable. filterChecked can pass in an additional filter parameter to return a subset of the selected goods, which will not be discussed here.

Select all and invert selection logic

With the filterChecked function, we can easily calculate the derived state checkedAll. Select all or not:

// All election
const checkedAll = cartData.length !== 0 && filterChecked().length === cartData.length

Write the functions of select all and anti select all:

const onCheckedAllChange = newCheckedAll => {
  // Construct a new tick map
  let newCheckedMap: CheckedMap = {}
  // All election
  if (newCheckedAll) {
    cartData.forEach(cartItem => {
      newCheckedMap[cartItem.id] = true
    })
  }
  // If you deselect all, you can directly assign the map as an empty object
  setCheckedMap(newCheckedMap)
}

If yes - select all, each item id of the checkedMap is assigned to true. -Invert selection to assign checkedMap as an empty object.

Rendering product subcomponents

{cartData.map(cartItem => {
  const { id } = cartItem
  const checked = checkedMap[id]
  return (
      <ItemCard
        key={id}
        cartItem={cartItem}
        checked={checked}
        onCheckedChange={onCheckedChange}
      />
  )
})}

It can be seen that the logic of whether to check is passed to the sub components easily.

Performance optimization of React.memo

At this point, the basic shopping cart needs have been realized.

But now we have new problems.

This is a defect of React, which by default has almost no performance optimization.

Let's take a look at the moving picture demonstration:



At this time, there are 5 items in the shopping cart. When you look at the printing of the console, each time you click the checkbox, it will trigger the re rendering of all the sub components.

If we have 50 items in the shopping cart, we change the checked status of one of them, which will also cause 50 sub components to re render.

We think of an api: React.memo, which is basically equivalent to the shouldComponentUpdate in the class component. What if we use this api to make the subcomponent re render only when the checked changes?

OK, let's go to the writing of subcomponents:

// Optimization strategy of memo
function areEqual(prevProps: Props, nextProps: Props) {
  return (
    prevProps.checked === nextProps.checked
  )
}

const ItemCard: FC<Props> = React.memo(props => {
  const { checked, onCheckedChange } = props
  return (
    <div>
      <checkbox 
        value={checked} 
        onChange={(value) => onCheckedChange(cartItem, value)} 
      />
      <span>commodity</span>
    </div>
  )
}, areEqual)

In this optimization strategy, we think that as long as the checked in the incoming props is equal in the two previous renderings, the sub components will not be re rendered.

bug caused by old value of React Hook

Is it finished here? Actually, there are bug s here.

Let's take a look at bug recovery:



If we first click the check of the first product, and then click the check of the second product, you will find that the check status of the first product is gone.

After checking the first product, our latest checkedMap at this time is actually

{ 1: true }

Because of our optimization strategy, the second product did not re render after the first product was checked,

Note that the functional component of React is re executed every time it is rendered, resulting in a closure environment.

Therefore, the onCheckedChange obtained by the second product is still in the function closure of the previous rendering shopping cart component, so the checkedMap is naturally the original empty object in the function closure of the last time.

const onCheckedChange: OnCheckedChange = (cartItem, checked) => {
    const { id } = cartItem
    // Note that the checkedMap here is still the original empty object!!
    const newCheckedMap = Object.assign({}, checkedMap, {
      [id]: checked,
    })
    setCheckedMap(newCheckedMap)
  }

Therefore, after the second product is checked, the correct checkedMap is not calculated as expected

{ 
  1: true, 
  2: true
}

It's the wrong calculation

{ 2: true }

This results in the first item being dropped.

This is also a notorious stale value issue with React Hook closures.

Then there is a simple solution. In the parent component, use React.useRef to pass the function to the child component through a reference.

Because ref has only one reference in the whole life cycle of React component, the latest function value in the reference can always be accessed through current, and there will be no problem of stale value of closure.

// You need to pass ref to the subcomponent so that the subcomponent can get the latest function reference without re rendering
  const onCheckedChangeRef = React.useRef(onCheckedChange)
  // Note that you should point the reference in ref to the latest function in each render.
  useEffect(() => {
    onCheckedChangeRef.current = onCheckedChange
  })

  return (
    <ItemCard
      key={id}
      cartItem={cartItem}
      checked={checked}
+     onCheckedChangeRef={onCheckedChangeRef}
    />
  )

Sub components

// Optimization strategy of memo
function areEqual(prevProps: Props, nextProps: Props) {
  return (
    prevProps.checked === nextProps.checked
  )
}

const ItemCard: FC<Props> = React.memo(props => {
  const { checked, onCheckedChangeRef } = props
  return (
    <div>
      <checkbox 
        value={checked} 
        onChange={(value) => onCheckedChangeRef.current(cartItem, value)} 
      />
      <span>commodity</span>
    </div>
  )
}, areEqual)

At this point, our simple performance optimization is complete.

Custom hook

So in the next scenario, we will meet the similar demand of all selection and anti selection. Shall we repeat this? This is unacceptable. We use custom hook s to abstract these data and behaviors.

And this time, we use useReducer to avoid the trap of closing the old value (dispatch keeps a unique reference in the component's life cycle, and can always operate to the latest value).

import { useReducer, useEffect, useCallback } from 'react'

interface Option {
  /** The id of the key used to record the check status in the map */
  key?: string;
}

type CheckedMap = {
  [key: string]: boolean;
}

const CHECKED_CHANGE = 'CHECKED_CHANGE'

const CHECKED_ALL_CHANGE = 'CHECKED_ALL_CHANGE'

const SET_CHECKED_MAP = 'SET_CHECKED_MAP'

type CheckedChange<T> = {
  type: typeof CHECKED_CHANGE;
  payload: {
    dataItem: T;
    checked: boolean;
  };
}

type CheckedAllChange = {
  type: typeof CHECKED_ALL_CHANGE;
  payload: boolean;
}

type SetCheckedMap = {
  type: typeof SET_CHECKED_MAP;
  payload: CheckedMap;
}

type Action<T> = CheckedChange<T> | CheckedAllChange | SetCheckedMap
export type OnCheckedChange<T> = (item: T, checked: boolean) => any

/**
 * Functions such as check, select all and invert selection are provided
 * Function to filter the selected data
 * Automatically eliminate obsolete items when updating data
 */
export const useChecked = <T extends Record<string, any>>(
  dataSource: T[],
  { key = 'id' }: Option = {}
) => {
  const [checkedMap, dispatch] = useReducer(
    (checkedMapParam: CheckedMap, action: Action<T>) => {
      switch (action.type) {
        case CHECKED_CHANGE: {
          const { payload } = action
          const { dataItem, checked } = payload
          const { [key]: id } = dataItem
          return {
            ...checkedMapParam,
            [id]: checked,
          }
        }
        case CHECKED_ALL_CHANGE: {
          const { payload: newCheckedAll } = action
          const newCheckedMap: CheckedMap = {}
          // All election
          if (newCheckedAll) {
            dataSource.forEach(dataItem => {
              newCheckedMap[dataItem.id] = true
            })
          }
          return newCheckedMap
        }
        case SET_CHECKED_MAP: {
          return action.payload
        }
        default:
          return checkedMapParam
      }
    },
    {}
  )

  /** Check status change */
  const onCheckedChange: OnCheckedChange<T> = useCallback(
    (dataItem, checked) => {
      dispatch({
        type: CHECKED_CHANGE,
        payload: {
          dataItem,
          checked,
        },
      })
    },
    []
  )

  type FilterCheckedFunc = (item: T) => boolean
  /** After filtering out the check box, you can pass in the filter function to continue filtering */
  const filterChecked = useCallback(
    (func: FilterCheckedFunc = () => true) => {
      return (
        Object.entries(checkedMap)
          .filter(entries => Boolean(entries[1]))
          .map(([checkedId]) =>
            dataSource.find(({ [key]: id }) => id === Number(checkedId))
          )
          // It's possible to delete the id directly after checking it. Although the id is in the checkedMap, the data source has no such data
          // First filter out the empty items to ensure that the external incoming func does not get undefined
          .filter(Boolean)
          .filter(func)
      )
    },
    [checkedMap, dataSource, key]
  )
  /** Select all or not */
  const checkedAll =
    dataSource.length !== 0 && filterChecked().length === dataSource.length

  /** Select all and invert function */
  const onCheckedAllChange = (newCheckedAll: boolean) => {
    dispatch({
      type: CHECKED_ALL_CHANGE,
      payload: newCheckedAll,
    })
  }

  // When updating data, delete the checked data if it is no longer in the data
  useEffect(() => {
    filterChecked().forEach(checkedItem => {
      let changed = false
      if (!dataSource.find(dataItem => checkedItem.id === dataItem.id)) {
        delete checkedMap[checkedItem.id]
        changed = true
      }
      if (changed) {
        dispatch({
          type: SET_CHECKED_MAP,
          payload: Object.assign({}, checkedMap),
        })
      }
    })
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dataSource])

  return {
    checkedMap,
    dispatch,
    onCheckedChange,
    filterChecked,
    onCheckedAllChange,
    checkedAll,
  }
}

In this case, it is very simple to use in components:

const {
  checkedAll,
  checkedMap,
  onCheckedAllChange,
  onCheckedChange,
  filterChecked,
} = useChecked(cartData)

We have done all the complicated business logic in the custom hook, including removing the invalid id after data update and so on. Go to promote it to the team's partners and let them get off work early.

summary

In this paper, a real shopping cart needs to be optimized step by step. In this process, we must have a further understanding of the advantages and disadvantages of React Hook.

After using the custom hook to extract the general logic, the amount of code in our business components is greatly reduced, and other similar scenarios can be reused.

React Hook brings a new development mode, but it also brings some pitfalls. It is a double-edged sword. If you can use it reasonably, it will bring you great power.

Thank you for reading. I hope this article can inspire you.

Posted by Karamja on Fri, 20 Mar 2020 13:28:36 -0700