How to wait for DOM commit in react

Viewed 59

I am working on a ReactJS app that uses Webassembly with Emscripten. I need to run a series of algorithms from Emscripten in my Js and I need to show the results as they come, not altogether at the end. Something like this:

  • Run the first algo
  • Show results on-screen form first algo
  • Run the second algo AFTER the results from the first algo are rendedred on the screen (so that the user can keep checking them)
  • Show results on screen from the second algo, and so on.

To achieve this I'm using "UseEffect" like this:

useEffect(() => {
    (
      async function AlgoHandler(){
        if (methodsToRun.length <= 0) {
          setGlobalState('loadingResults', false);
          return;
        }
        const method = methodsToRun[0];
        let paramsTypes = method[1].map((param) => param[0][2]);
        let runAlgo = window.wasm.cwrap(method[0], 'string', paramsTypes); 
        let params = method[1].map(
          (param) => document.getElementById(param[0][0]).value
        );
        let result = await runAlgo(...params);
        setGlobalState('dataOutput', (prev) => [...prev, ...JSON.parse(result)]);
        await sleep(100);
        setGlobalState('methodsToRun', (prev) => prev.filter(m => m != method));
      })();
  }, [methodsToRun]);

The problem is that this code "kind of" works, this is because useEffect ensures rendering before my callback, but DOM commits don't always happen, and the user doesn't see any updates on the UI. I would like to make it wait for the DOM commit of the component, not just the rerender, this is a component life-cycle in react: enter image description here

So, any ideas on how I can achieve this?

1 Answers

The DOM commits always happen (React guarantees that), but the browser may not have had a chance to paint those commits.

You could use the usual setTimeout(/*...*/, 0) trick:

useEffect(() => {
  setTimeout(() => {
    async function AlgoHandler(){
      if (methodsToRun.length <= 0) {
        setGlobalState('loadingResults', false);
        return;
      }
      const method = methodsToRun[0];
      let paramsTypes = method[1].map((param) => param[0][2]);
      let runAlgo = window.wasm.cwrap(method[0], 'string', paramsTypes); 
      let params = method[1].map(
        (param) => document.getElementById(param[0][0]).value
      );
      let result = await runAlgo(...params);
      setGlobalState('dataOutput', (prev) => [...prev, ...JSON.parse(result)]);
      await sleep(100);
      setGlobalState('methodsToRun', (prev) => prev.filter(m => m != method));
    })();
  }, 0);
}, [methodsToRun]);

That's not a guarantee, though. If you want to be really, really sure, wait for a requestAnimationFrame callback before doing the setTimeout(/*...*/, 0) call:

useEffect(() => {
  requestAnimationFrame(() => {
    setTimeout(() => {
      async function AlgoHandler(){
        if (methodsToRun.length <= 0) {
          setGlobalState('loadingResults', false);
          return;
        }
        const method = methodsToRun[0];
        let paramsTypes = method[1].map((param) => param[0][2]);
        let runAlgo = window.wasm.cwrap(method[0], 'string', paramsTypes); 
        let params = method[1].map(
          (param) => document.getElementById(param[0][0]).value
        );
        let result = await runAlgo(...params);
        setGlobalState('dataOutput', (prev) => [...prev, ...JSON.parse(result)]);
        await sleep(100);
        setGlobalState('methodsToRun', (prev) => prev.filter(m => m != method));
      })();
    }, 0);
  });
}, [methodsToRun]);

You know from the fact rAF called your callback that the browser is just about to paint, so the setTimeout should be sufficient to wait until after the painting is complete.

This is sufficiently gnarly and magic-esque that it's likely best to wrap it in a hook:

const useAfterPaintEffect = (callback, deps) => {
  // (Explanation of how this works goes here)
  useEffect(() => {
    requestAnimationFrame(() => {
      setTimeout(() => {
        callback();
      }, 0);
    });
  }, deps);
};

then

useAfterPaintEffect(() => {
  async function AlgoHandler(){
    if (methodsToRun.length <= 0) {
      setGlobalState('loadingResults', false);
      return;
    }
    const method = methodsToRun[0];
    let paramsTypes = method[1].map((param) => param[0][2]);
    let runAlgo = window.wasm.cwrap(method[0], 'string', paramsTypes); 
    let params = method[1].map(
      (param) => document.getElementById(param[0][0]).value
    );
    let result = await runAlgo(...params);
    setGlobalState('dataOutput', (prev) => [...prev, ...JSON.parse(result)]);
    await sleep(100);
    setGlobalState('methodsToRun', (prev) => prev.filter(m => m != method));
  })();
}, [methodsToRun]);

Note that that hook doesn't allow for cleanup. To do that, you'd have to make your callback return another callback (to do the actual work) and an optional cleanup callback.

const useAfterPaintEffect = (callback, deps) => {
  // (Explanation of how this works goes here)
  const [run, cleanup] = callback();
  useEffect(() => {
    requestAnimationFrame(() => {
      setTimeout(() => {
        run();
      }, 0);
    });
    return cleanup;
  }, deps);
};

then

useAfterPaintEffect(() => {
  return [
    () => {
      async function AlgoHandler(){
        if (methodsToRun.length <= 0) {
          setGlobalState('loadingResults', false);
          return;
        }
        const method = methodsToRun[0];
        let paramsTypes = method[1].map((param) => param[0][2]);
        let runAlgo = window.wasm.cwrap(method[0], 'string', paramsTypes); 
        let params = method[1].map(
          (param) => document.getElementById(param[0][0]).value
        );
        let result = await runAlgo(...params);
        setGlobalState('dataOutput', (prev) => [...prev, ...JSON.parse(result)]);
        await sleep(100);
        setGlobalState('methodsToRun', (prev) => prev.filter(m => m != method));
      })();
    },
    // This specific one doesn't need a cleanup callback
  ];
}, [methodsToRun]);
Related