preface

This article starts with a basic shopping cart requirement and takes you step-by-step through the holes and optimizations in React Hook

Through this article you can learn:

✨React Hook + TypeScript practice for writing business components

✨ How to optimize performance with React. Memo

✨ How to avoid the closure trap of Hook

✨ how to abstract a simple and easy to use custom hook

Preview the address

sl1673495.github.io/react-cart

Code warehouse

The code covered in this article has been organized into the Github repository, and a sample project has been built with CRA. The section on performance optimization can be opened to the console to view the rerendering.

Github.com/sl1673495/r…

Decomposition of demand

As a shopping cart requirement, it necessarily involves several requirement points:

  1. Check, select all and reverse select.
  2. Calculate the total price according to the selected item.

Need to implement

To get the data

First we request the shopping cart data, which is not the focus of this article. This can be done through custom request hooks, or plain useState + useEffect.

const getCart = (a)= > {
  return axios('/api/cart')}const {
  // Shopping cart data
  cartData,
  // A method to rerequest data
  refresh,
} = useRequest < CartResponse > getCart
Copy the code

Check logical implementation

Let’s consider using an object as a mapping table, using the checkedMap variable to record all checked item ids:

type CheckedMap = {
  [id: number]: boolean,
}
// The item is checked
const [checkedMap, setCheckedMap] = useState < CheckedMap > {}
const onCheckedChange: OnCheckedChange = (cartItem, checked) = > {
  const { id } = cartItem
  const newCheckedMap = Object.assign({}, checkedMap, {
    [id]: checked,
  })
  setCheckedMap(newCheckedMap)
}
Copy the code

Calculate the ticked total price

Then use reduce to implement a function that calculates the sum of prices

// cartItems integral sum
const sumPrice = (cartItems: CartItem[]) = > {
  return cartItems.reduce((sum, cur) = > sum + cur.price, 0)}Copy the code

Then you need a function that filters out all the selected items

// Returns all selected cartItems
const filterChecked = (a)= > {
  return (
    Object.entries(checkedMap)
      // Use this filter to filter out all items whose checked status is true
      .filter((entries) = > Boolean(entries[1]))
      // Map the selected list according to id from cartData
      .map(([checkedId]) = > cartData.find(({ id }) = > id === Number(checkedId)))
  )
}
Copy the code

Finally, combine these two functions and the price comes out:

// Calculate the bonus points
const calcPrice = (a)= > {
  return sumPrice(filterChecked())
}
Copy the code

Some people may wonder why a simple logic would pull out so many functions, but I’ll explain that I’ve simplified the real requirements to make the article easier to read.

In a real-world scenario, where different types of items may be aggregated separately, filterChecked is essential. FilterChecked can pass in an additional filtering parameter to return a subset of checked items, which I won’t go into here.

Select all anti-select logic

With the filterChecked function, we can also easily calculate the derived state checkedAll:

/ / all
constcheckedAll = cartData.length ! = =0 && filterChecked().length === cartData.length
Copy the code

Write all and anti-all functions:

const onCheckedAllChange = (newCheckedAll) = > {
  // Construct a new checkbox map
  let newCheckedMap: CheckedMap = {}
  / / all
  if (newCheckedAll) {
    cartData.forEach((cartItem) = > {
      newCheckedMap[cartItem.id] = true})}// Assign map to an empty object without selecting all
  setCheckedMap(newCheckedMap)
}
Copy the code

If it is

  • select allthecheckedMapAssigns true to each item ID of.
  • The selectedthecheckedMapAssign to an empty object.

Render the goods child component

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

As you can see, the check logic is easily passed to the child components.

React.memo performance optimization

At this point, the basic shopping cart requirements have been fulfilled.

But now we have a new problem.

This is a drawback of React, which by default has almost no performance optimizations.

Let’s take a look at the GIF demo:

The cart now has 5 items, and if you look at the console print, it’s growing by a factor of 5 every time you click on the checkbox, it triggers a re-rendering of all the subcomponents.

If we have 50 items in the cart and we change the checked state of one of the items, that will also cause 50 subcomponents to be rerendered.

We came up with an API called React. Memo, which is basically the same API as shouldComponentUpdate in the class component. What if we used this API to make subcomponents rerender only when checked changes?

Ok, let’s get to the subcomponent writing:

// Memo optimization strategy
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>goods</span>
    </div>
  )
}, areEqual)
Copy the code

Under this optimization strategy, we assume that as long as checked in the props passed is equal, we don’t re-render the child components.

React Hook bug caused by stale values

Is that it? Actually, there is a bug here.

Let’s look at the bug restore:

If we click the check box of the first item and then click the check box of the second item, you will find that the check state of the first item is gone.

After checking the first item, our latest checkedMap at this point is actually

{1:true }
Copy the code

Due to our optimization strategy, the second item is not re-rendered after the first item is checked.

Note that React’s functional components are re-executed each time they are rendered, resulting in a closure environment.

So the second item gets the onCheckedChange in the function closure of the previous rendering of the shopping cart component, so the checkedMap is the original empty object in the function closure of the previous rendering.

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

Therefore, when the second item is checked, the correct checkedMap is not computed as expected

{
  1: true.2: true
}
Copy the code

I calculated the wrong one

{ 2: true }
Copy the code

This causes the checked status of the first item to be dropped.

This is also the problem with the notoriously stale values of React Hook closures.

A simple solution is to use the React. UseRef in the parent component to pass the function to the child component via a reference.

Because ref has only one reference throughout the life of the React component, current is always available to access the latest function value in the reference without the problem of stale closure values.

Const onCheckedChangeRef = react. useRef(onCheckedChange) // The ref must be passed to the child so that the child can get the latest function reference without rerendering Be sure to point the ref reference after each render to the latest function in the next render. useEffect(() => { onCheckedChangeRef.current = onCheckedChange }) return ( <ItemCard key={id} cartItem={cartItem} checked={checked}+ onCheckedChangeRef={onCheckedChangeRef}
    />
  )
Copy the code

Child components

// Memo optimization strategy
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>goods</span>
    </div>
  )
}, areEqual)
Copy the code

At this point, our simple performance tuning is complete.

Custom hook of useChecked

So in the next scenario, we’re going to have to do the same thing over and over again. This is unacceptable, and we use custom hooks to abstract the data and behavior.

And this time we use the useReducer to avoid the trap of closing old values (dispatches remain unique references throughout the life of the component and can always operate on the latest values).

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

interface Option {
  /** The key used to record the check status in the map is usually id */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

/** * provides a function to filter the checked data * automatically eliminate the old items when the data is updated */
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
          if (newCheckedAll) {
            dataSource.forEach((dataItem) = > {
              newCheckedMap[dataItem.id] = true})}return newCheckedMap
        }
        case SET_CHECKED_MAP: {
          return action.payload
        }
        default:
          return checkedMapParam
      }
    },
    {}
  )

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

  type FilterCheckedFunc = (item: T) = > boolean
  /** Filter out the check items can be passed into 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 is possible to delete the dataSource from the checkedMap
          // Filter out the empty items to ensure that external func does not get undefined
          .filter(Boolean)
          .filter(func)
      )
    },
    [checkedMap, dataSource, key]
  )
  /** Whether to select all */
  constcheckedAll = dataSource.length ! = =0 && filterChecked().length === dataSource.length

  /** 全选反选函数 */
  const onCheckedAllChange = (newCheckedAll: boolean) = > {
    dispatch({
      type: CHECKED_ALL_CHANGE,
      payload: newCheckedAll,
    })
  }

  // If the selected data is no longer in the data, it will be deleted
  useEffect((a)= > {
    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,
  }
}
Copy the code

When used within a component, it is easy:

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

We have removed all the complex business logic in the custom hook, including removing invalid ID after data update and so on. Spread it to the team and let them leave work early.

Custom hook useMap

One day, we need to use a map to record other things according to the ID of the shopping cart. Suddenly, we find that the above custom hook also packages the logic of map processing and so on. We can only set the value of map to true/false. Not enough flexibility.

We took useMap one step further and let useCheckedMap be developed on top of it.

useMap

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

export interface Option {
  /** is used as a key in a mapkey? :string
}

export type MapType = {
  [key: string] :any
}

export const CHANGE = 'CHANGE'

export const CHANGE_ALL = 'CHANGE_ALL'

export const SET_MAP = 'SET_MAP'

export type Change<T> = {
  type: typeof CHANGE
  payload: {
    dataItem: T
    value: any}}export type ChangeAll = {
  type: typeof CHANGE_ALL
  payload: any
}

export type SetCheckedMap = {
  type: typeof SET_MAP
  payload: MapType
}

export type Action<T> = Change<T> | ChangeAll | SetCheckedMap
export type OnValueChange<T> = (item: T, value: any) = > any

/** * Provides map operation functionality * automatically removes stale items when data is updated */
export const useMap = <T extends Record<string.any>>(
  dataSource: T[],
  { key = 'id' }: Option = {}
) => {
  const [map, dispatch] = useReducer(
    (checkedMapParam: MapType, action: Action<T>) = > {
      switch (action.type) {
        // Single value change
        case CHANGE: {
          const { payload } = action
          const { dataItem, value } = payload
          const { [key]: id } = dataItem
          return {
            ...checkedMapParam,
            [id]: value,
          }
        }
        // All values change
        case CHANGE_ALL: {
          const { payload } = action
          const newMap: MapType = {}
          dataSource.forEach((dataItem) = > {
            newMap[dataItem[key]] = payload
          })
          return newMap
        }
        // Replace map completely
        case SET_MAP: {
          return action.payload
        }
        default:
          return checkedMapParam
      }
    },
    {}
  )

  /** The value of a map item is changed */
  const onMapValueChange: OnValueChange<T> = useCallback((dataItem, value) = > {
    dispatch({
      type: CHANGE,
      payload: {
        dataItem,
        value,
      },
    })
  }, [])

  // If the map is no longer in the dataSource, delete it
  useEffect((a)= > {
    dataSource.forEach((checkedItem) = > {
      let changed = false
      if (
        // Map contains this item
        // The item was not found in the data source
        checkedItem[key] inmap && ! dataSource.find((dataItem) = > checkedItem[key] === dataItem[key])
      ) {
        delete map[checkedItem[key]]
        changed = true
      }
      if (changed) {
        dispatch({
          type: SET_MAP,
          payload: Object.assign({}, map),
        })
      }
    })
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dataSource])

  return {
    map,
    dispatch,
    onMapValueChange,
  }
}
Copy the code

This is a custom hook for a generic map operation that takes into account closure traps and allows for the deletion of old values.

On top of this, we implement useChecked above

useChecked

import { useCallback } from 'react'
import { useMap, CHANGE_ALL, Option } from './use-map'

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

export type OnCheckedChange<T> = (item: T, checked: boolean) = > any

/** * provides a function to filter the checked data * automatically eliminate the old items when the data is updated */
export constuseChecked = <T extends Record<string, any>>( dataSource: T[], option: Option = {} ) => { const { map: checkedMap, onMapValueChange, dispatch } = useMap( dataSource, Option) const {key = 'id'} = option /** check status change */ const onCheckedChange: OnCheckedChange<T> = useCallback( (dataItem, checked) => { onMapValueChange(dataItem, checked) }, [onMapValueChange] ) type FilterCheckedFunc = (item: */ const filterChecked = useCallback((func? : FilterCheckedFunc) => { const checkedDataSource = dataSource.filter(item => Boolean(checkedMap[item[key]]) ) return func ? checkedDataSource.filter(func) : CheckedDataSource}, [checkedMap, dataSource, key]) /** Whether to select all */ const checkedAll = dataSource. Length! == 0 && filterChecked().length === dataSource. Length/const onCheckedAllChange = (newCheckedAll:) // select const payload =!! newCheckedAll dispatch({ type: CHANGE_ALL, payload, }) } return { checkedMap: checkedMap as CheckedMap, dispatch, onCheckedChange, filterChecked, onCheckedAllChange, checkedAll, } }Copy the code

conclusion

Through a real shopping cart requirement, this paper completed the optimization and pit step by step. In this process, we must have a further understanding of the advantages and disadvantages of React Hook.

After extracting common logic using custom hooks, the amount of code in our business components is greatly reduced, and other similar scenarios can be reused.

React Hook introduces a new development model, but it also brings some pitfalls. It’s a double-edged sword that can be very powerful if used properly.

Thank you for reading, and I hope this article inspires you.