Search code examples
javascriptreactjstypescripttsx

Preloading images in Typescript / React: Maximum update depth exceeded


I try to have a progress bar before loading a website. I try to preload images (and other items) and convert it in a percentage of loading complete.

I have the following script to preload images in a React webpage:

import React from "react";
import ReactDOM from "react-dom";

interface AppInterface {
    loading: boolean,
    totalImagesLoaded: number
}

class App extends React.Component<{}, AppInterface> {
    
    private totalImagesToLoad = 0;

    constructor(props: {}) {
        super(props);
        this.state = {
            loading: false,
            totalImagesLoaded: 0
        }
    }

    setLoading = (loadingstate: boolean) => {
        this.setState({
            loading: loadingstate
        })
    }

    totalLoadingProgress = () => {
        this.setState({
            totalImagesLoaded: this.state.totalImagesLoaded + 1
        })
    }

    cacheImagesprogress = (srcArray: Array<string>) => {
        const self = this;

        srcArray.map((src:string, index:number) => {
            const xhr = new XMLHttpRequest();

            xhr.open("GET", src, true);
            xhr.onload = function (e) {
                if (xhr.readyState === 4) {
                    if (xhr.status === 200) {
                        self.totalLoadingProgress();
                    }
                }
            };
            xhr.send();
        });

    }
    
    componentDidMount() {
        this.setLoading(true);

        const folder = 'https://picsum.photos/'; // settings
        const images = [
            folder + '200/300',
            folder + '400/500',
            folder + '800/500',
        ]
        this.totalImagesToLoad = images.length;
        this.cacheImagesprogress(images);
    }

    componentDidUpdate() {
        if(this.state.totalImagesLoaded >= this.totalImagesToLoad) {
            this.setLoading(false);
        }
    }

    render() {
        return (
            <main className="app">
                {this.state.loading ? (
                    <>
                      <div>Loading..</div>
                    </>
                ) : (
                    <>
                        Done loading!
                    </>
                )}
            </main>
        )
    }
}

export default App;

const container = document.getElementById('root');

// Create a root.
const root = ReactDOM.createRoot(container);

// Initial render
root.render(<App />);


See this Sandbox: https://codesandbox.io/s/maximum-update-depth-exceeded-preload-images-forked-6jdhvx

I get the error "Maximum update depth exceeded"

This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.

How can I refactor this so I can show a progress bar based on the number of images loaded? (4 of 5 images loaded = 80% progress bar)


Solution

  • This'll be because in componentDidUpdate you call this.setState which will cause a rerender, this then causes componentDidUpdate to run, which causes the this.setState to run again...and so on. React will rerender even if the value is the same as the previous one. It's notable that this only happens in the old style class components you are using. useState in the hooks pattern does not rerender if it's the same.

    Update componentDidUpdate to check the loading flag:

        componentDidUpdate() {
            if(loading !== false && this.state.totalImagesLoaded >= this.totalImagesToLoad) {
                this.setLoading(false);
            }
        }
    
    

    Things would be a lot cleaner with hooks (classes are an unused API nowadays. It's terrible but they have not moved the main site to the beta docs yet. The old docs make a lot of use of the dead classes API). and also using fetch (which is promise based) though:

    import React, { useState, useEffect, FC } from "react";
    
    const folder = 'https://picsum.photos/'; // settings
    const images = [
        folder + '200/300',
        folder + '400/500',
        folder + '800/500',
    ]
    
    
    const App: FC = () => {
      const [totalImagesLoaded, setTotalImagesLoaded] = useState<number>(0)
      const [loading, setLoading] = useState<boolean>(true)
    
      useEffect(() => {
        Promise.all(images.map((image) =>
          fetch(image).then((res) => {
            if (!response.ok) return
            setTotalImagesLoaded((prev) => prev + 1)
          })
        )).finally(() => setLoading(false))
      }, [])
    
      return (
        <main className="app">
          {loading ? (
            <>
              <div>Total: {Math.round((totalImagesLoaded / images.length))*100}%</div>
            </>
          ) : (
            <>Done loading!</>
          )}
        </main>
      )
    }
    
    export default App;
    
    const container = document.getElementById('root');
    
    // Create a root.
    const root = ReactDOM.createRoot(container);
    
    // Initial render
    root.render(<App />);
    
    

    For the progress bar, I would recommend not reinventing the wheel and introducing a design system like Chakra or material-ui, which have it readily available to pipe in.