Which is in keeping with internal discussions on the optimal solution. Send it out to see if anyone has a better solution

Recently RXJS author Ben Lesh tweeted twitter.com/benlesh/sta… As this shows, the useCallback problem is very serious and the community has discussed many practices, but there are still many problems.

UseCallback Problem cause

Let’s review the way components were written before hooks

The class components

export class ClassProfilePage extends React.Component<any,any> { showMessage = () => { alert('Followed ' + this.props.user); }; handleClick = () => { setTimeout(this.showMessage, 3000); }; render() { return <button onClick={this.handleClick}>Follow</button>; }}Copy the code

The functional components

export function FunctionProfilePage(props) {
 const showMessage = () => {
    alert('Followed ' + props.user);
  };


 const handleClick = () => {
    setTimeout(showMessage, 3000);
  };


 return (
    <button onClick={handleClick}>Follow</button>
  );
}
Copy the code

When you click the button and switch user from A to B, the class component displays B and the function component displays A, and it’s hard to say which is more reasonable

import React, { useState} from "react"; import ReactDOM from "react-dom"; import { FunctionProfilePage, ClassProfilePage } from './profile' import "./styles.css"; function App() { const [state,setState] = useState(1); return ( <div class> <button onClick={() => { setState(x => x+x); }}>double</button> <div>state:{state}</div> <FunctionProfilePage user={state} /> // Click to always display snapshot values <ClassProfilePage User ={state} /> // click always display the latest value </div>); } const rootElement = document.getElementById("root"); ReactDOM.render(<App />, rootElement);Copy the code

Codesandbox. IO/s/dreamy – wa…

When you have both Functional and class components in your application, you face UI inconsistency. Although react officially says function is for UI consistency, this is based on the fact that all components are Functional. In fact, this assumption is almost impossible to make. If you use both class components, it is possible to keep the UI consistent (both display the latest values). Once you use a mixture of class and functional components in a page (using useref temporary state is also considered a class component), There is the possibility of UI inconsistency

Snapshot or Latest value

The main difference between function and class is that they can be converted to each other by default. Whether a snapshot is appropriate or the latest value is appropriate depends on your business scenario

You can actually get the snapshot value in class, and you can get the latest value in function

Class by saving the snapshot before triggering asynchrony

export class ClassProfilePage extends React.Component<any,any> { showMessage = (message) => { alert('Followed ' +message); }; HandleClick = () => {const message = this.props. User // Save the snapshot before triggering the asynchronous function setTimeout(() =>showMessage(message)), 3000); }; render() { return <button onClick={this.handleClick}>Follow</button>; }}Copy the code

Function retrieves the latest value through the ref container

export function FunctionProfilePage(props) {
 const ref = useRef("");
  useEffect(() => {
    ref.current = props.user;
  });
 const showMessage = () => {
 console.log('ref:',ref)
    alert("Followed " + props.user +',' + ref.current);
  };


 const handleClick = () => {
    setTimeout(showMessage, 3000);
  };


 return <button onClick={handleClick}>function Follow</button>;
}
Copy the code

It’s a classic function closure problem

  • Free variables accessed by a closure can be captured before asynchronous functions are executed: implementing snapshot functionality
  • The latest value can be read through ref in asynchronous function execution
for(var i=0; i<10; I++) {setTimeout (() = > console. The log (' val: 'I)) / / to get the latest value} for (var I = 0; i<10; i++){ setTimeout(((val) => console.log('val:',val)).bind(null,i)); Const ref = {current: null} for(var I =0; i<10; i++){ ref.current = i; setTimeout(((val) => console.log('val:',ref.current)).bind(null,ref)); } for (var I = 0; i < 10; I ++) {// let t = I; setTimeout(() => { console.log("t:", t); }); }Copy the code

Rerender mechanism

Functional and Class components differ in how they handle snapshots, but their rerendering mechanisms are not that different

ShouldComponentUpdate and pureComponent optimizations are not considered here

  • This. setState: Unconditionally rerender without comparing old to new
  • This. forceUpdate: Unconditional rerender, no comparison between old and new
  • Render (props); render (props); render (props)
  • Ancestor component context changes: do not make props changes assumptions

We found that the react default re-render mechanism didn’t make any assumptions about props at all, leaving performance tuning to the framework. React-redux is based on shouldComponent, mobx-react is based on this.forceUpdatehooks to do some performance optimizations

Problems brought about

We found that the functional component and the class component behave differently even without the hooks themselves, since hooks are currently only used in function components, This leads to hooks that reflect some of the problems that would otherwise be the functional component’s programming thinking.

The use of hooks introduced two strong assumptions that led to a sea change in programming thinking

  • This can only be used in functional components: we need to deal with the latest values
  • Side effects (including rerender and effect) based on reference equality of old and new values: Forces us to program immutable

The above two carry a lot of mental burden

Stale Closure and Infinite Loop

These two problems are two sides of the same coin, usually to solve one problem, may lead to another problem

The simplest case is when a component relies on the parent component’s callback and an internal USeffect relies on that callback

The following is a typical search scenario

function Child(props){ console.log('rerender:') const [result,setResult] = useState('') const { fetchData } = props; useEffect(() => { fetchData().then(result => { setResult(result); }) },[fetchData]) return ( <div>query:{props.query}</div> <div>result:{result}</div> ) } export function Parent(){ const  [query,setQuery] = useState('react'); const fetchData = () => { const url = 'https://hn.algolia.com/api/v1/search?query=' + query return fetch(url).then(x => x.text()) } return ( <div> <input onChange={e => setQuery(e.target.value)} value={query} /> <Child fetchData={fetchData}  query={query}/> </div> ) }Copy the code

One problem with this code is that every Parent rerenders a new fetchData, because fetchData is the deP of Child useEffect, and every change to the fetchData causes the Child component to refire effect. On the one hand this can cause performance problems, and if effect is not idempotent it can also cause business problems (what if buried points are reported in effect)

Solution 1

Stale Closure: no longer uses useEffect to listen on fetchData: Stale Closure and UI are inconsistent

useEffect(() => { fetchData().then(result => { setResult(result); })},[]) // Remove fetchData dependenciesCopy the code

The parent query is updated, but the child’s search is not updated but the child’s Query display is updated, which results in an inconsistent UI for the child

Solution 2

On the basis of idea 1, brush token is strengthened

// child
useEffect(() => {
 fetchData().then(result => {
      setResult(result);
    })
},[refreshToken]);


// parent
<Child fetchData={fetchData} query={query} refreshToken={query} />
Copy the code

Question:

  • If there are many effects in the sub-components, the mapping between refreshToken and Effect needs to be established
  • Trigger eslint-hook warning, which may trigger eslint-hook auto fix, causing a bug
  • FetchData might still fetch the old closure, right?

For better semantics and to avoid esLint errors, you can use a custom wrapper useDep to solve this problem

useDepChange(() => fetchData().then(result => { setResult(result); }) },[fetchData]) },[queryToken]); / / only trigger changes in the dep, approximately equal to the componentWillReceivePropsCopy the code
  • This is essentially abandoning the EXHAUSTIVE check of ESlint-hook, which may cause you to forget to add some dependencies, so you need to be very careful when writing code

Solution 3

UseCallback wraps fetchData, which actually transfers the control logic of effect strong brushing from Callee to caller

// parent const fetchData = useCallback(() => { const url = 'https://hn.algolia.com/api/v1/search?query=' + query return  fetch(url).then(x => x.text()) },[query]); // child useEffect(() => { fetchData().then(result => { setResult(result); }) },[fetchData])Copy the code

Question:

  • If the child’s useEffect relies on more callbacks, all callbacks need to be wrapped with useCallback. Once one of them is not wrapped with useCallback, all the previous efforts will be wasted
  • The Parent’s fetchData might have been fetched from another component, but it did not have permission to control the immutability of the fetchData, which caused a remote Parent component to change the fetchData. This caused the Child to refresh effect crazily recently, which required layers of useCallback processing for the callback to avoid the problem
  • UseCallback does not support semantics and contains the risk of caching
  • Component API design: We find that components need to be designed with an eye to whether the incoming component is mutable, but this dependency is not reflected in the interface
<Button onClick={clickHandler} /> //Copy the code

Solution 4

Use useEventCallback as an escape pod. This is also the usage given in the official documentation, useEventCallback

// child
useEventCallback(() => {
  fetchData().then(result => {
     setResult(result);
  });
},[fetchData]);
function useEventCallback(fn, dependencies) {
  const ref = useRef(() => {
    throw new Error('Cannot call an event handler while rendering.');
  });

  useEffect(() => {
    ref.current = fn;
  }, [fn, ...dependencies]);

  return useCallback(() => {
    const fn = ref.current;
    return fn();
  }, [ref]);
}
Copy the code

There are still problems with that,

Solution 5:

To embrace mutable is to forgo the React snapshot function and achieve a vue3 like coding style

In fact, hook + MOBx == vue3, vue3 API can actually be used mobx + Hook simulation

The problem is: The possible abandonment of concurrent mode (which focuses more on UX and may be more important for general business development efficiency and maintainability)

Caller convention:

  • Callback of the parent component is passed to the child components: always get to is the latest state of the parent component (through useObservable | useRef)

The called party convention

  • Don’t rely on callback as a useEffect: Because we’ve defined callback to always be up to date, we actually avoid the stale closure problem, so we don’t need to use callback as a depdency

  • Use only custom wrapped hooks (because useEffect triggers eslint-hook warnings every time, and useEffect is not that semantic)

  • UseMount: only triggers on mount (updates do not trigger)

  • UseUpdateEffect: only triggers when updating (mount does not trigger)

  • UseDepChange: this function is similar to useEffect. It does not trigger wanring

// parent.js export observer(function VueParent(){ const [state] = useState(observable({ query: 'reqct' })) const fetchData = () => { const url = 'https://hn.algolia.com/api/v1/search?query=' + state.query return fetch(url).then(x => x.text()) } return ( <div> <input onChange={e => state.query = e.target.value} value={state.query} /> <Child fetchData={fetchData} query={state.query} /> </div> ) }) // child.js export function observer(VueChild(props){  const [result,setResult] = useState('') useMount(() => { props.fetchData().then(result => { setResult(result); }) }) useUpdateEffect(() => { props.fetchData().then(result => { setResult(result); })},[props.query]) /* Or use useDepChange useUpdateEffect(() => {props.fetchData(). Then (result => {setResult(result); }) },[props.query]) */ return ( <div> <div>query: {props.query}</div> <div>result:{result}</div> </div> ) })Copy the code

Solution 6

UseReducer This is also the official recommendation of the more orthodox approach

Let’s take a closer look at our code and see why the fetchData in the parent component changes every time because our parent component render generates a new function every time. Why does it generate a new function every time? We rely on query so we can’t fetch it out of the component. In addition to using useCallback, we can also move the fetchData logic into the useReducer. The React useReducer does not have built-in async handling, because the dispatches returned by the useReducer are always the same and we just need to pass the dispatches to the child components. Thankfully, there are community wrappers that can be used directly, such as Zustand, which I currently prefer, especially if callback relies on multiple states. Codesandbox. IO/s/a lot/ha…

function Child(props) {
  const [result, setResult] = useState("");
  const { fetchData } = props;
  useEffect(() => {
    console.log("trigger effect");
    fetchData().then(result => {
      setResult(result);
    });
  }, [props.query, fetchData]);
  return (
    <>
      <div>query:{props.query}</div>
      <div>result:{result}</div>
    </>
  );
}
const [useStore] = create((set, get) => ({
  query: "react",
  setQuery(query) {
    set(state => ({
      ...state,
      query
    }));
  },
  fetchData: async () => {
    const url = "https://hn.algolia.com/api/v1/search?query=" + get().query;
    const x = await (await fetch(url)).text();
    return x;
  }
}));
export function Parent() {
  const store = useStore();
  const forceUpdate = useForceUpdate();
  console.log("parent rerender");
  useEffect(() => {
    setInterval(() => {
      forceUpdate({});
    }, 1000);
  }, [forceUpdate]);
  return (
    <div>
      <input
        onChange={e => store.setQuery(e.target.value)}
        value={store.query}
      />
      <Child fetchData={store.fetchData} query={store.query} />
    </div>
  );
}
Copy the code

Solution 7:

This is the best possible solution I think, Core problem lies in js language for concurrent | immutable | functional programming weak support, such as (thread local object | mutable and immutable tags | algebraic effects). As a result, React officials forced various hacks on language facilities at the framework level, causing various counterintuitive things. It might be a better solution to do React with another language (such as ReasonML).


A commercial breaks in. Bytedance is looking for talented front-end engineers and Node.js engineers to join us and do fun things together. Please email us or send your resume to [email protected]. The school recruit stamp here (also welcome intern students.