background

Recently, I found a problem in the development. I found that after requesting an ApI and setState to display a DOM, I could directly obtain the ref of my DOM node, which had just changed from hidden state to displayed state, after setState. I was very surprised, and the specific code was similar to the following:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const titleRef = useRef(null);

  useEffect(() = > {
    // API request axios.post().then...
    Promise.resolve(1).then(() = > {
      setShowTitle(true);
      // Prints titleref. current 

awakening age

still not null🧐
console.log("titleRef.current", titleRef?.current); }) }, []) return ( <div className="App"> <h3>Test useEffect execute ref update demo!</h3> <button>button</button> {showTitle && <h2 ref={titleRef}>The awakening of s</h2>} </div> ); } Copy the code

useRef

In React, we can access DOM nodes using the current property of the value returned by React. UseRef (sometimes used to store temporary variables, because ref is the same pointer every time a function is executed) :

It would have been possible to retrieve the DOM node as shown in the simple useEffect, because the useEffect is scheduled in the React commit process after the two Fiber trees are swapped

import React, { useEffect, useRef } from "react";

export default function App() {
  const titleRef = useRef(null);
  useEffect(() = > {
    console.log(titleRef.current); // You can get the actual node corresponding to the DOM
  }, [])

  return (
    <div className="App">
      <h2 ref={titleRef}>The awakening of s</h2>
    </div>
  );
}
Copy the code

But when we setState to display the DOM in a normal function and print ref.current, we return null:

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const titleRef = useRef(null);

  const toggleShowTitle = () = >{ setShowTitle(! showTitle);console.log("titleRef.ref.current", titleRef.current);
  }

  console.log("render");

  return (
    <div className="App">
      <h3>Test useEffect execute ref update demo!</h3>
      <button onClick={toggleShowTitle}>button</button>
      {showTitle && <h2 ref={titleRef}>The awakening of s</h2>}
    </div>
  );
}
Copy the code

When we setState in useEffect and then print the latest ref, we can’t get the corresponding DOM:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const titleRef = useRef(null);

  useEffect(() = > {
    setShowTitle(true);
    console.log("titleRef.ref.current", titleRef? .current);// null
  }, [])

  console.log("render");

  return (
    <div className="App">
      <h3>Test useEffect execute ref update demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>The awakening of s</h2>}
    </div>
  );
}
Copy the code

This is easy to understand becausesetStateIs executed asynchronously, i.e., after setState is completed, it does not render immediately, insteadReactAsynchronous scheduling is performed, and the synchronous code printed later is executed first, so the actual DOM cannot be retrieved.

But when we put setState into the setTimeout or promise. then callback, something magical happens:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const titleRef = useRef(null);

  useEffect(() = > {
    // setTimeout(() => {
    // setShowTitle(true);
    // console.log("titleRef.ref.current", titleRef? .current);
    // });
    Promise.resolve(1).then(() = > {
      setShowTitle(true);
      console.log("titleRef.ref.current", titleRef?.current);
    })
  }, [])

  console.log("render");

  return (
    <div className="App">
      <h3>Test useEffect execute ref update demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>The awakening of s</h2>}
    </div>
  );
}
Copy the code

Verify synchronous execution: If we print showTitle directly after setShowTitle, we won’t be able to get the latest showTitle=true due to closure issues. Resolve. Of course, it’s easy to get the latest state in a class component via this.state.showTitle. How do you get the latest state in a function component for synchronous updates? The answer is obtained from the Fiber object of the function component itself:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const titleRef = useRef(null);
  const appRef = useRef(null);

  useEffect(() = > {
    Promise.resolve(1).then(() = > {
      setShowTitle(true);
      console.log(appRef.current[Object.keys(appRef.current)[0]].return.alternate.memoizedState);
      console.log("titleRef.ref.current", titleRef?.current);
    })
  }, [])

  console.log("render");

  return (
    <div className="App" ref={appRef}>
      <h3>Test useEffect execute ref update demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>The awakening of s</h2>}
    </div>
  );
}
Copy the code

As for why fromalternateThe reason is that the Fiber node corresponding to the APP function component has been committed after synchronization, that is, the process of switching between two Fiber trees has been completed

Why is setState execution and rendering synchronized in setState or promise. then

React setState is an asynchronous setState. React setState is an asynchronous setState. To make sure that the state and props are updated only after the Reconciliation and flushing phase, the beginWork phase of React is completed. That is, multiple setStates are processed asynchronously in batches, and then a new DOM tree is calculated based on the new state to ensure that the current page can interact, and then finally switch between the current Fiber tree and the calculated new Fiber tree.

In the demo below, although we reset state twice after useEffect, the entire process is rendered only twice.

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const [showParagram, setShowParagram] = useState(false);

  const titleRef = useRef(null);
  const appRef = useRef(null);

  useEffect(() = > {
    setShowTitle(true);
    setShowParagram(true);
  }, [])

  console.log("render"); // Render twice

  return (
    <div className="App" ref={appRef}>
      <h3>Test useEffect execute ref update demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>The awakening of s</h2>}
      {showParagram && <p ref={titleRef}>A nice</p>}
    </div>
  );
}
Copy the code

But if you place it twice in a setTimeout or promise. then callback, you render it three times:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const [showParagram, setShowParagram] = useState(false);

  const titleRef = useRef(null);
  const appRef = useRef(null);

  useEffect(() = > {
    setTimeout(() = > {
      setShowTitle(true);
      setShowParagram(true); }}), [])console.log("render"); // Render three times

  return (
    <div className="App" ref={appRef}>
      <h3>Test useEffect execute ref update demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>The awakening of s</h2>}
      {showParagram && <p ref={titleRef}>A nice</p>}
    </div>
  );
}
Copy the code

React17 and its predecessors don’t batch update setState in setTimeout, promise. then or native event callbacks. Why I don’t feel the need to know, Because The Plan for React18 came out a few days ago, React renders renders renders automatically batching for fewer renders in React 18.

React18 reforming batch update

React18 can be replaced in codesandBox (using reactdom.createroot instead of reactdom.render) to see the effect:

import ReactDOM from "react-dom";

import App from "./App";

const rootElement = document.getElementById("root");
const root = ReactDOM.createRoot(rootElement);
root.render(<App />);
Copy the code

As you can see, even in setTimeout, there are only two batches of updates rendered:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const [showParagram, setShowParagram] = useState(false);

  const titleRef = useRef(null);
  const appRef = useRef(null);

  useEffect(() = > {
    setTimeout(() = > {
      setShowTitle(true);
      setShowParagram(true); }}), [])console.log("render"); 

  return (
    <div className="App" ref={appRef}>
      <h3>Test useEffect execute ref update demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>The awakening of s</h2>}
      {showParagram && <p ref={titleRef}>A nice</p>}
    </div>
  );
}
Copy the code

So what if we don’t want to batch update? Using the flushSync module of ReactDOM: we can see three more renders! The demo address

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
import { flushSync } from 'react-dom';

export default function App() {
  const [showTitle, setShowTitle] = useState(false);
  const [showParagram, setShowParagram] = useState(false);

  const titleRef = useRef(null);
  const appRef = useRef(null);

  useEffect(() = > {
    setTimeout(() = > {
      flushSync(() = > setShowTitle(true));
      flushSync(() = > setShowParagram(true)); }}), [])console.log("render");

  return (
    <div className="App" ref={appRef}>
      <h3>Test useEffect execute ref update demo!</h3>
      <button>button</button>
      {showTitle && <h2 ref={titleRef}>The awakening of s</h2>}
      {showParagram && <p ref={titleRef}>A nice</p>}
    </div>
  );
}
Copy the code

The last

So if I want to do some side effects after a hidden node is displayed, such as the Echarts component needs setOptions or other cases after getting the data: As we can see from the original demo, we can get the Echarts instance from the then callback directly after requesting the data.

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showCharts, setShowCharts] = useState(false);
  const chartsRef = useRef(null);

  useEffect(() = > {
    / / API request
   axios.post(xxx).then((res) = > {
      showCharts(true); chartsRef.current.getEchartsInstance().setOptions(res); }}), [])return (
    <div className="App">
      {showCharts && <EchartsComponent ref={chartsRef}>The awakening of s</EchartsComponent>}
    </div>
  );
}
Copy the code

If React18 does a batch update (asynchronous), we can wrap the flushSync layer in setShowTitle(true), or we can simply use showChartsstate as a new useEffect dependency. When showCharts=true is when the Echarts node is displayed, we can get its real DOM and do it.

Another option is callbackRef, which performs a callback when a ref is updated, as opposed to useRef(which does not subscribe)

The pseudocode is as follows:

import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

export default function App() {
  const [showCharts, setShowCharts] = useState(false);
  // ...
  
  const chartsRef = useCallback(node= > {
    if(node ! = =null) { getEchartsInstance().setOptions(xxx); }} []);return (
    <div className="App">
      {showCharts && <EchartsComponent ref={chartsRef}>The awakening of s</EchartsComponent>}
    </div>
  );
}
Copy the code

conclusion

Programming is always a process of exploration, ok, continue to watch the Age of Awakening, if there is something wrong, I hope you correct!

Refer to the link

  • RFClarification: why is setState asynchronous? # 11527
  • The Plan for React 18
  • Automatic batching for fewer renders in React 18
  • Callback Refs
  • Image credit – Wlop