-1

When a particular custom React component I have first mounts, it uses useEffect to kick off a long multistep process of loading data that it will then render. The component isn't always rendered, so this expensive process isn't always needed. The process also makes some exotic calls through libraries that aren't especially amenable to standard caching libraries like Axios or React Query.

I also have a progress display component and functions which can be called to update the state of progress display; these functions are passed into the long multistep process and called periodically to keep the user updated as various steps are completed. The objective is to allow the user to accurately distinguish between a process just taking a while because it has so many steps and a hang (the first often just looks like the second, but the second remains possible).

However, the updates to the state of the progress display get automatically batched up during the whole long process and thus the intended ongoing communication to the user doesn't correctly happen. If I try to use flushSync around those updates I get a warning that "flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering." The states are then still not correctly updated right away as steps are completed.

While the error makes sense, is there a canonical (or if not, at least working) way around this that allows one component's state to be updated (and the component to be re-rendered) several times while another component's componentDidMount() lifecycle hook is running?

Here is some code (index.tsx) which seems to still demonstrate the issue and hopefully doesn't introduce any artifacts of simplification that have a workaround which doesn't address the original problem. When run, you can still see the error "flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering." and you can see that the progress bar does not proceed as expected, though the final state is as expected.

import React, { useEffect, useState } from 'react';
import { flushSync } from 'react-dom';
import ReactDOM from 'react-dom/client';
import { Line } from 'rc-progress';
//Progress bar functionality: normally a separate component imported in here
interface ProgressProps {
    currentStepDescription: string,
    percent: number,
    allDone: boolean,
}
const Progress = function(props: ProgressProps) {
    console.log('Rendering progress; allDone is', props.allDone);
    return (props.allDone ? <span>All done!</span> : <>
        <span>{props.currentStepDescription}</span>
        <Line percent = {props.percent}/>
    </>);
}
//Loading script: Again, normally separate and imported
const wait = (ms : number) => new Promise((r, j)=>setTimeout(r, ms));
const slowDataLoad = async function(
    start: number,
    resetProgressStep: ((currentStepDescription: string) => void),
    completeProgressStep: (() => void),
    abortCalled: (() => boolean),
) : Promise<number[] | undefined> {
    let result : number[] = [];
    if(abortCalled()) { return; }
    resetProgressStep('First part');
    for(let i=start; i<10; i++) {
        if(abortCalled()) { return; }
        await wait(1000); //Simulates library call fetching from an exotic API
        result.push(i); completeProgressStep();
    }
    resetProgressStep('Second part');
    for(let i=start+10; i<20; i++) {
        if(abortCalled()) { return; }
        await wait(1000); //Simulates calls based on 1st results
        result.push(i); completeProgressStep();
    }
    return result;
}
const PrimaryContent = function(props: {start: number}) {
    const [dataToShow, setDataToShow] = useState<number[]>([]);
    const [progressProps, setProgressProps] = useState<ProgressProps>(function(){
        console.log('Re-initiating progress state.');
        return {currentStepDescription: 'Loading', percent: 100, allDone: true};
    });
    const resetProgressStep = function(currentStepDescription: string) {
        flushSync(() => {
        console.log(
            'Now starting ' + currentStepDescription +
            ', incl. setting allDone to false.'
        );
        setProgressProps({currentStepDescription, percent: 0, allDone: false});
    })};
    const completeProgressStep = function() {flushSync(() => {
        console.log('Making progress. allDone is', progressProps.allDone);
        setProgressProps(Object.assign({}, progressProps, {
            percent: progressProps.percent + 10
        }));
    })};
    const finishProgress = function() {flushSync(() => {
        console.log('Setting allDone to true.'); //only happens once!
        setProgressProps(Object.assign({}, progressProps, {allDone: true}));
    })};
    const getAndDisplayData = async function(
        start: number,
        abortCalled: (() => boolean),
    ) {
        const dataToShow = await slowDataLoad(
            start, resetProgressStep, completeProgressStep, abortCalled
        );
        if(typeof dataToShow !== 'undefined') {
            if(abortCalled()) { return; }
            setDataToShow(dataToShow);
            finishProgress();
        }
    };
    useEffect(() => {
        //See beta.reactjs.org/learn/synchronizing-with-effects#fetching-data
        let isAborted = false;
        const abortCalled = function() {return isAborted};
        getAndDisplayData(props.start, abortCalled);
        return() => {
            isAborted = true;
            console.log('Aborted first of 2 duplicated useEffect calls.')
        };
    },[]); //https://stackoverflow.com/a/71434389/798371
    return (<>
            <Progress {...progressProps} />
            <br/>
            <span>{dataToShow.join(', ')}</span>
    </>); //actual display fn is way more complex
}
const App = function() {
    const start = 0; //normally parsed from URL & made more safe etc.
    return (start === undefined) ?
    (<span>'Displaying empty-state page here.'</span>) :
    (<PrimaryContent start={start}/>);
}
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<React.StrictMode><App /></React.StrictMode>);
WBT
  • 2,249
  • 3
  • 28
  • 40
  • 1
    From your description it sounds like your loading process is 100% synchronous? Like, a big loop that's crunching numbers and blocking the browser from doing anything else. I ask because that's pretty rare thing to have to do. If that's really what you have, you're probably going to need to either split it into smaller pieces of work, which then are worked on in a sequence of `setTimeout`s, or move the computation to a webworker. But it's hard to give specific suggestions with no code. – Nicholas Tower Mar 15 '23 at 01:01
  • No. It uses nonblocking async/await a lot, but the top-level await is in a function called from within useEffect. It's mostly fetching from APIs rather than crunching numbers. The browser as a whole can do other things while waiting for responses, but React seems to not be able to update state elsewhere until everything kicked off in useEffect completes and that component is considered finished rendering. – WBT Mar 15 '23 at 01:02
  • 1
    Ok, then with that description i don't see why changes to state would be batched. If you set state and then await a promise, the call stack empties and react rerenders, without waiting for that promise. Could you please reduce your code to a small example which demonstrates the problem, and include that in your question. – Nicholas Tower Mar 15 '23 at 01:07
  • `but React seems to not be able to update state elsewhere until everything kicked off in useEffect` - if it's truly a non-blocking process, then there's nothing stopping react from rerendering **on updates from the process, wink wink** (setStates). Ultimately, a repro is needed – Adam Jenkins Mar 15 '23 at 01:08
  • `However, the updates to the state of the progress display get automatically batched up during the whole long process and thus the intended ongoing communication to the user doesn't correctly happen` This is very ambiguous. And also, note the warning on this page: https://beta.reactjs.org/reference/react-dom/flushSync – Adam Jenkins Mar 15 '23 at 01:15
  • It sounds like you're looking for a state machine. Try looking into those. – Slava Knyazev Mar 15 '23 at 01:22
  • Code example added. – WBT Mar 15 '23 at 03:03
  • @Adam I had seen the warning there, but am not finding other ways to address the core of this question. If someone were able to answer it without `flushSync()`, that would be fine, but showing something I had tried seemed like it should have been more appropriate on SO than the reception so far. – WBT Mar 15 '23 at 03:26

1 Answers1

0

It's because you're mutating state:

        setProgressProps(Object.assign(progressProps, {
            percent: progressProps.percent + 1
        }))

should be:

        setProgressProps(Object.assign({},progressProps, {
            percent: progressProps.percent + 1
        }))

(personal preference is):

setProgressProps(progress => ({
   ...progress,
   percent: progress.percent+1
}))

Get rid of your flushSync's, they do nothing

Adam Jenkins
  • 51,445
  • 11
  • 72
  • 100
  • Modified the example to include {} first in params to Object.assign, but the issue persists as it was. I agree the flushSync's aren't doing anything as noted in the "already rendering" error message they trigger instead, but thought it helpful to demonstrate that and avoid having that as the answer here on SO ("it's just batching state updates- flushSync is the 'obvious' solution!"). – WBT Mar 15 '23 at 13:36
  • @WBT - ok, now I can see why as well - change it to the callback approach to `setState` (my "personal preference"). What's happening is you've captured `progressProps`. In effect (heh), all the stuff (callbacks) that's outside the `useEffect` needs to be inside the `useEffect`. – Adam Jenkins Mar 15 '23 at 13:48
  • Thanks for spotting those! Accepting answer for the particular issue encountered, but leaving question open in case there is a more canonical way to accomplish the goals as described before the example (which don't seem original or unique). – WBT Mar 15 '23 at 15:19