javascriptreactjstypescriptreact-hooksreact-lifecycle

How do I show details of a loading process while data is loading in useEffect?


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>);

Solution

  • 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