2

foreword

This article aims to summarize the use skills of React Hooks and the problems that need to be paid attention to in the process of use, which will add some reasons for the problems and solutions. However, please note that the solutions given in the article are not necessarily fully applicable. There are many solutions to the problem. Maybe your team has given corresponding specifications for these problems, or you have already solved these problems. solutions to form a better understanding. So your focus should be on you aware of these issues while using React on these

useState hook

initialized state

If a certain state of the component needs to rely on a lot of calculations to get the initial value, generally we will define a function to initialize the state

  • in the class component

    state = {
     state1: calcInitialState()
    }

    No problem, calcInitialState will only be executed once even if the component is re-rendered multiple times without the component being remounted

  • In function components, there are two ways

    const state1 = useState(calcInitialState) // 组件多次渲染时,calcInitialState 仅会被执行一次
    const state1 = useState(calcInitialState()) // 组件每次渲染时,calcInitialState 都会被执行

    Each time the function component is re-rendered, the function component itself will be executed. When it is rendered for the first time, useState will read the initial value. If it is an initial value function, it will be executed, and the return value of the function will be used as the initial state. , the two expressions behave the same. However, in the subsequent re-rendering process, although useState will not read the default state value, nor will it do anything with the default state value, but the calcInitialState in the second writing method will still be executed, which is meaningless. For the internal running process, see the source code mountState and updateState

How state is captured (this & closure)

Some time ago, I shared common React Hooks principles and source code within the team. At that time, I mentioned that whether it is a class component or a function component, their state is stored on the fiber corresponding to the component. The flow of status update of function components and class components is shown in the following figure:

file

For details, see the source code and updateReducer

For the rendered part (JSX), you can always get the latest state. It's just that the way to get the state is different. The class component points to the state stored on the fiber node through useState , and the function component is obtained through the return value of the function 06213155aa5a05. The problem is that the state obtained by the function component is stored in the closure. Yes, this closure is executed by useState .

On the other hand, for functional components, we need to pay special attention to the concept of "rendering". Every time the function component is rendered, the function declared inside or the returned UI (JSX) can only capture the props and state of the current rendering, which is no problem for UI (JSX). But for functions inside function components, especially functions with delayed callbacks, you need to pay special attention to whether the state and props captured in the callback function are what you expect when the callback function is executed.

To avoid the troubles caused by the closure problem in functional components, you need to understand and remember the following two sentences

  • Each render of a functional component has its own props and state
  • Each render of a function component has its own event handler

state granularity

State granularity is too fine

When writing class components, there is almost no need to consider the problem of state granularity, because developers can always declare all state at once or update all state at once, like this

handleClick = () => {
    this.setState({
    currentPage: 2,
    pageSize: 20,
    total: 100
  })
}

Most developers are not stupid enough to use setState three times to update the three states, but only in function components

const handleClick = () => {
  setCurrent(2)
  setPageSize(20)
  setTotal(100)
}

Seeing this code, um, may make people feel a little uncomfortable. The problem is that the update granularity is too fine. In fact, the currentPage, pageSize, and total of a paging component often need to be updated at the same time, but triggering setXXX multiple times is still It makes people feel vaguely uneasy, even though multiple trigger updates may be merged into one by React's batchUpdate mechanism, but multiple updates are triggered when the setXXX method is executed out of React's context, such as in the callback at the end of async.

At this point, we can store an object in useState to keep the associated states together. You can also use useReducer to manage multiple states.

State granularity is too coarse

When a state has a certain complexity, I do not recommend violently inserting the state declaration of the class component into the useState, because this may introduce the defects of the coarse-grained state in the class.

Problem 1: It is difficult to find state logic that can be reused

When a component has more and more states, the readability and maintainability of the component will become worse and worse. Many people should have a deep understanding, like this:

ps : intercepted from the real business code

class XXX extends React.Component{
  constructor(props: any) {
    super(props);
    this.state = {
      tableListMap: {},
      showPreview: false,
      showRegModal: false,
      dataSource: [],
      columns: [],
      tablePartitionList: [],
      incrementColumns: [],
      loading: false,
      isChecked: {},
      isShowImpala: false,
      tableListSearch: {},
      schemaList: [],
      fetching: false,
      tableListLoading: false,
      bucketList: [],
      showPreviewPath: false,
      previewPath: '',
      currentObject: { object: [''], index: 0, bucket: '' },
      isCompressed: false,
      matchType: null
    };
  }
}

Just imagine, in a function component, a useState is stuffed with so many states, not to mention whether you can find the reusable state and logic in it, even if you have a keen eye and find that it is compatible with other components Multiplexed state and logic. With a high probability, it is also difficult to extract the reusable state logic without guaranteeing that the current component (historical code) will not fail.

Question 2: Putting unrelated states in the same useState may make state updates uncontrollable

For example, if there is a button on the page, when the button is clicked, two pieces of data need to be obtained from different interfaces at the same time and rendered on the page. At this time, if the two pieces of data are stored in the same useState

function DataViewer (props) {
    const [dataMap, setDataMap] = useState({ data1: undefined, data2: undefined })
  
  const loadData1 = async () => {
    if (visible) {
      const data1 = await fetchData1()
      setDataMap({ ...dataMap, data1 })
    }
  }

  const loadData2 = async () => {
    if (visible) {
      const data2 = await fetchData2()
        setDataMap({ ...dataMap, data2 })
    }
  }
  
  const handleClick = () => {
    loadData1()
    loadData2()
  }
 // ...
}

The problem is obvious, as long as both requests complete, no matter whether it succeeds or fails, there will be no data in the dataMap returned by the request that completed first.

In the class component, this problem basically does not occur, because the current latest state can always be obtained through this, and there will be no problem of state overwriting in multiple updates. Of course, in the function component, you can also use useRef to temporarily store the interface data, and then update the state together, but you need to write some extra logic, so this black technology will not be introduced here.

The first solution at this time is to temporarily store the data returned by the first interface in a variable and wait until the second interface is completed before updating the dataMap, like this

const loadDataMap = async () => {
  if (visible) {
     const data1 = await fetchData1()
     const data2 = await fetchData2()
     setDataMap({ data1, data2 })
  }
}

The problem with this is that it is necessary to wait for the completion of the first request before initiating the second request, which is not friendly to the user experience.

Well, if you want two interfaces in parallel, you can also use Promise.allSettled to process two requests in parallel

const loadDataMap = () => {
  if (visible) {
     Promise.allSettled([ fetchData1(), fetchData2() ])
        .then(results => {
        //...
     })
  }
}

It looks like there is no problem. However, if there is additional processing logic for the state, return value, etc. of the interface, you need to stuff all the processing logic of the interface into the .then callback, and this method must be updated after both interfaces are completed. The state then displays the data on the page, and it is impossible to detect whether one of the interfaces is in the pending state. This method does not seem to be so friendly.

It seems that there are only two perfect solutions

  1. To avoid closures, you just pass in a function when updating the state, like this

    setDataMap(dataMap => ({ ...dataMap, data2 }))
  2. state segmentation

    const [ data1, setData1 ]  = useState()
    const [ data2, setData2 ]  = useState()

There is often more than one way to solve the problem. You need to choose the way you think is more appropriate according to the actual business situation.

How to design state granularity

It is said in the QA of the official document

Putting all the states in the same useState call, or each field corresponding to a useState call, both works. Components are more readable when you find a balance between these two extremes and combine related state into several separate state variables. If the logic of the state starts to get complicated, we recommend a to manage it, or using a custom Hook.

In my opinion, it is a good practice to aggregate related states and split unrelated states. For example, the three states of currentPage, pageSize, and total of the pager component are placed in the same useState, and the returned data of different requests is split into different useStates. In addition, in some cases, the logic of the state is relatively complex. At this time, useReducer can also be used to manage the state, so that some complex logic can be extracted into the reducer.

Two ways of status update

Whether it is a function component or a class component, there are two ways to update the state: setState(newState) and setState(oldState => newState) , the difference between them is that one focuses on the result and the other focuses on the purpose. setState(newState) is used to describe the new state, and setState(oldState => newState) is used to describe what changes should be made in the new state compared to the old state.

This may be a bit abstract. In simple terms, setState(newState) replaces the old state with the new state, and setState(oldState => newState) is used to calculate the new state from the old state.

useEffect hook

Why useEffect is needed

In theory, function components are simply used for rendering, which is the so-called pure function. In fact, this was the case before React Hooks. And other operations such as data acquisition, setting timers, modifying the DOM, etc. are called side effects.

Why can't the side effect be executed directly inside the function component?

  • There are some side effects operations that may affect rendering, such as modifying the DOM
  • There are some side effects operations that need to be cleared, such as timers
  • If side effects are performed directly inside the function component, then the function component will perform these operations every time it is re-rendered, and there is no way to control when these operations are performed and when they are not performed.

How does useEffect solve these problems?

  • The function wrapped by useEffect will be executed after the browser rendering is completed, ensuring that it will not affect the rendering of the component. In addition, the execution of the function wrapped by useEffect is out of the execution context of the function component itself, so it will not affect the execution of the function component itself.
  • A function wrapped in useEffect can return a function that clears side effects
  • useEffect can pass in an array of dependencies, and only perform side effects when the dependencies change

These features of useEffect are a bit like event callbacks, except that the triggering of event callback functions depends on dom events such as clicks, input, etc., while the functions wrapped by useEffect depend on changes in dependencies. In many cases, it is a better choice to put some side effect operation into the event callback function, so that you can not worry about useEffect dependencies.

How useEffect captures props and state

In the function wrapped by useEffect, the way of capturing props and state is the same as that of ordinary functions, depending on the execution context of the function component itself. UseEffect doesn't do anything special inside like data binding, fiber dependencies, etc. Therefore, each render of the function component has its own effects . After the rendering of the function component is completed, the generated effects will be stored on the fiber corresponding to the component, waiting for a specific timing to execute these effects (side effects). Even if the page has been re-rendered many times when some asynchronous callbacks in effects are executed, the props and state captured in these asynchronous callback functions are still the state and props of the component in the rendering that produced these effects.

Dependencies for useEffect

Which should be placed in useEffect's dependencies

In theory. useEffect 's mental model is closer to the effect that executes when some value changes, but sometimes to ensure that the props and state captured in the effect are what you expect, you have to use all the components used in the effect The variables inside are placed in the dependencies. If you set the corresponding lint rules in the project, the lint tool will also tell you to do this, but this seems to conflict with useEffect 's mental model.

The consequence of this conflict is that the effect may be executed frequently, the following example is a counter that increments every second

function Counter () {
  const [count, setCount] = useState(1)
  useEffect(() => {
    const timerId = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(timerId);
  }, [count]);
  
  return (
      <>{count}</>
  )
}

If the count in the dependency of this useEffect is removed, the callback function in the timer will always execute setCount(1 + 1) , which is not what we expect. Well, to be honest, put count in a dependency, but then the timer will be cleared and created frequently, which may affect the frequency of the timer callback firing, which is not what we expect.

So far, the problem has not been solved, I still prefer useEffect's dependency is used to trigger the effect, not to solve the closure problem, then I can only find a way to remove the useEffect's dependency on count .

How to reduce useEffect's dependencies

  • Eliminate unnecessary captures in useEffect
    The useEffect in the above example can be written as

    useEffect(() => {
      const timerId = setInterval(() => {
     setCount(count => count + 1);
      }, 1000);
      return () => clearInterval(timerId);
    }, []);
  • Decouple dependencies from effects
    Or the example of the timer counter, if we want to pass a step property to the component through props, which is used to tell the component the size of the value to increment every second
<Counter step={2}/>

So the Counter component becomes like this

function Counter ({step}) {
  const [count, setCount] = useState(1)
  useEffect(() => {
    const timerId = setInterval(() => {
      setCount(count => count + step);
    }, 1000);
    return () => clearInterval(timerId);
  }, [step]);
  
  return (
      <>{count}</>
  )
}

The problem now is that when the value of step changes, the timer is still restarted. Now it's time for useReducer play.

function Counter ({step}) {
  const [count, dispatch] = useReducer(reducer, 0);

  function reducer(state, action) {
    if (action.type === 'tick') {
      return state + step;
    } else {
      throw new Error('type in action is not true');
    }
  }

  useEffect(() => {
    const timerId = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(timerId);
  }, []);
  
  
  return (
      <>{count}</>
  )
}

So now the question comes:
Q:_Why can't we include _dispatch_ in dependencies? _
A: Because React assures us that _dispatch_ (from useReducer), setState (from useState) and ref.current (from useRef), even if components are rendered multiple times, their reference addresses will not change
Q: _Why is the step value captured in the reducer the latest? _
A: For useReducer , React only remembers its actions, not its reducers. That is to say, every time the component re-renders and executes useReducer, it will re-read the reducer
As mentioned above, useState and useReducer are the same thing in the source code. In actual use, compared to useState, useReducer allows us to update logic (reducer) and describing what happened (action) , which can be used to remove unnecessary dependencies.

Of course, in actual business, the scenarios in the above examples are rarely encountered. In most cases, only the value that you want to use to trigger the execution of the effect is put into the dependencies of useEffect. For scenarios that cannot be solved by the above two methods, useRef can also be used to bypass the annoying closure problem.

Should functions be used as dependencies of useEffect

Personal opinion is: most cases, functions should not be used as dependencies of useEffect. As for whether the function can be safely removed from the dependencies, the main point is

  • Does the function participate in the data flow of React?
    In short, it is to see if the variables inside the function component (except useRef) are used in this function. If a function does not participate in the React data flow, but is used in useEffect , then you should extract the function outside the component, so you can brainlessly remove the function in useEffect 's dependencies.
  • Whether the function is called asynchronously delayed
    When the function is called delayed, it is easy to cause the closure problem. At this time, even if the function is used as a dependency of the , the closure problem cannot be solved, but it may increase the trigger frequency of the effect. The section on variable data will introduce a method to ensure that the function automatically captures the latest value of the variable in the component when the function reference address remains unchanged.

Most of the time it's fine to not put the function in useEffect's dependencies. There may be some extremely special business scenarios. In this case, the function can only be wrapped with useCallback, and then placed in the dependencies of (I have never encountered this situation) .

useRef hook

According to my personal understanding, useRef is more like an instance attribute of a class component, that is, this.xxx. In the function component, useRef can be regarded as a container, you can manipulate the data in this container arbitrarily, and the reference address in this container will not change due to multiple re-rendering of the component. In my opinion, it is a cheater for function components and a unique tool for solving the closure problem in function components.

Features of useRef

  1. When the value stored by useRef changes, it does not cause the component to re-render
  2. It can be used to store variable data. When the component is rendered multiple times, the reference address of ref (the return value of useRef) itself can remain unchanged.

ps: The reason why useRef can guarantee that the reference address of the return value remains unchanged is that even if the component is rendered multiple times, the ref returned by useRef is still the ref returned when it is executed for the first time. When the function component is rendered for the first time, React will store the return value (ref) of useRef on the fiber node corresponding to the component. When the subsequent component is re-rendered, React will not do any processing on the useRef and directly return to the fiber node. Stored ref. Details can be found source mountRef and updateref

What useRef-based traits can do

  • Implement a custom hook to count the number of renders of the component

    const useRenderTimes = () => {
      const ref = useRef(0)
      ref.current += 1
      return ref.current
    }
  • Record a value from the last time the component was rendered

    const usePreValue = (value) => {
      const ref = useRef(undefined)
      const preValue = ref.current
      ref.current = value
      return preValue
    }
  • Avoid unnecessary re-renders
    If a state has nothing to do with rendering, then you can use useRef instead of useState . Remember the Counter component in the useEffect section above, if count is used as a dependency of useEffect, then the timer will be created/destroyed continuously, two solutions are given above, now let's talk about another Way. The idea is that as long as the count changes, the component is not re-rendered, then useRef can be used to store the count value. Of course, this method is only limited to the case where the count does not participate in rendering. or you can also store the value change in useRef At the same time to trigger the component to re-render .
    Such scenarios are actually very common. For example, there is a form. When the user completes the form, click the submit button to send the data to the backend through the interface. If the form is not a controlled component, then using useRef to store the form data is a better choice than because it won't cause unnecessary re- .

    function Counter () {
      const count = useRef(1)
      useEffect(() => {
     const timerId = setInterval(() => {
       count.current += 1;
     }, 1000);
     return () => {
       clearInterval(timerId)
       console.log(count.current)
     };
      }, []);
    }

Sometimes useState is used in function components to still get a certain value after the component is re-rendered, but we want to not trigger component updates when this value changes, or we want to avoid the closure problem caused by the immutable data of useState , then this is the time to use useRef.

How to think about useRef

In my opinion, useRef useState React Hooks. Knowing that has a sentence in an article that says,

Every developer who wants to get into the practice of hooking must keep this conclusion in mind, not being able to use useRef freely will make you lose nearly half of the power of hooking.

express approval.

useCallback Hook

Regarding useCallback, the introduction on the official website is

Pass an inline callback function and an array of dependencies as parameters to useCallback and it will return a memoized version of the callback function that will only be updated when a dependency changes. This is useful when you pass callbacks to child components that are optimized to use reference equality to avoid unnecessary rendering (eg shouldComponentUpdate ).

I saw the familiar vocabulary again - " dependency ", in order to ensure that the function wrapped in useCallback captures the value inside the function component at the current rendering time, all the values inside the function component referenced in the function wrapped in useCallback must be all in dependencies. In addition, please note that the role of useCallback introduced on the official website is - " performance optimization ".

Do you really need to wrap useCallback for every function in a function component? Take shouldComponentUpdate in the official document as an example, we define a function handleClick in the function component and wrap it with useCallback, and then pass it to the sub-component through props. In the sub-component, shouldComponentUpdate compared with handleClick to determine whether it needs to be updated.

function Parent () {
  const handleClick = useCallback(()=>{
    //...
  },[...])
  
  return (<Child handleClick={handleClick}/>)
}

class Child extends React.Component {
  shouldComponentUpdate(nextProps) {
    return this.props.handleClick !== nextProps.handleClick
  }
  // ...
}

Then there should be a question at this time, how big is the performance improvement. If you are interested, you might as well get started, write an example, and compare the performance performance panel, you should see how much useCallback improves performance, and according to From the test results, you can roughly get when you should use useCallback to improve performance. Another function of useCallback is to keep the function reference address unchanged. But it still regenerates the function when the dependencies change. If you want to keep the function reference address unchanged, you need to use useRef

The reason why I resist useCallback is that in my opinion, its function is relatively useless, and using useCallback, you must pay attention to dependencies, which will bring additional mental burden.

useMemo Hook

There are not many points to pay attention to when using useMemo, and the official documents are also very clear.

What useMemo does is

Pass the "create" function and the dependencies array as arguments useMemo and it will only recalculate the memoized value when a dependency changes. This optimization helps avoid expensive computations on every render.

Please focus on " high overhead calculation ", sometimes useMemo may not be needed

Things to keep in mind when using useMemo

You can use **useMemo** as a means of performance optimization, but don't take it as a semantic guarantee. future, React may choose to "forget" some previous memoized values and recalculate them on the next render, like freeing memory for offscreen components. Write code that will execute without useMemo first - then add useMemo to your code to optimize performance.

Closures in function components

Combining the above, it can be concluded that in the function component, the closure problem is mainly due to the delayed call of the function, whether it is the function wrapped by useEffect or the timer callback function or the callback function of the asynchronous request, the variables captured inside them are both and in the closure generated by the execution of the external function component, so if you want to avoid the trouble caused by the closure, there are two ideas

Reduce the dependence of internal functions on external variables

For example, in the above example of timer counter

setCount(count + 1)
// 替换为
setCount(count => count + 1)

Replacing immutable data with mutable data

The problem of closures is rarely encountered in class components because the state and props of components are accessed through this in class components. Although this.state and this.props point to immutable data, this is internally stored The data is mutable and the reference to this does not change. So is there something similar to this in function components? Yes, useRef .

  • For non-function types, useRef can be used instead of useState
    In this way, even the function that is called deferred can get the latest value through ref.current, because the function that is called deferred takes the reference address of the return value of useRef. This has also been used in the example above. It should be noted that if the value stored in useRef participates in rendering, such as

    function demo () {
     const text = useRef("")
      return <>{text.current}</>
    }

In this case, updating the value stored in useRef does not cause the view to re-render. But we can synchronize the view by updating another state (useState). If you can't find a state in the component that can trigger the update when the value inside useRef changes, you can also write a custom hook to force the update

function useForceUpdate () {
  const [, forceUpdate] = useReducer(x => x + 1, 0)
  return forceUpdate
}

By encapsulating it, you can get a useState that stores variable data

function useMutableState (init) {
      const stateRef = useRef(init)
    const [, updateState] = useReducer((preState, action) => {
          stateRef.current = typeof action === 'function'
            ? action(preState)
            : action
          return stateRef.current
    }, init)

    return [stateRef, updateState]
}
  • For function types, you can also use useRef to keep the function reference address unchanged, and the function automatically captures the latest value

    function useStableFn(fn, deps) {
      const fnRef = useRef();
      fnRef.current = fn;
      return useCallback(() => {
     return fnRef.current();
      }, []);
    }

Epilogue

It is difficult to summarize the real perfect best practices in React Hooks, and even the official documentation and blogs only describe the mental model of React Hooks. Some of the views or examples above violate the official mental model, and I have to admit that I am a lover of useRef. But for React Hooks in practice, there is no silver bullet. What matters is whether you understand how hooks work and whether you have your own guide to avoiding them.

Reference link


袋鼠云数栈UED
277 声望34 粉丝

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。