Can’t perform a React state update on an unmounted component

Total
0
Shares

Problem

I am writing an application in React and was unable to avoid a super common pitfall, which is calling setState(...) after componentWillUnmount(...).

I looked very carefully at my code and tried to put some guarding clauses in place, but the problem persisted and I am still observing the warning.

Therefore, I’ve got two questions:

  1. How do I figure out from the stack trace, which particular component and event handler or lifecycle hook is responsible for the rule violation?
  2. Well, how to fix the problem itself, because my code was written with this pitfall in mind and is already trying to prevent it, but some underlying component’s still generating the warning.

Browser console

Warning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
To fix, cancel all subscriptions and asynchronous tasks in the componentWillUnmount
method.
    in TextLayerInternal (created by Context.Consumer)
    in TextLayer (created by PageInternal) index.js:1446
d/console[e]
index.js:1446
warningWithoutStack
react-dom.development.js:520
warnAboutUpdateOnUnmounted
react-dom.development.js:18238
scheduleWork
react-dom.development.js:19684
enqueueSetState
react-dom.development.js:12936
./node_modules/react/cjs/react.development.js/Component.prototype.setState
react.development.js:356
_callee$
TextLayer.js:97
tryCatch
runtime.js:63
invoke
runtime.js:282
defineIteratorMethods/</prototype[method]
runtime.js:116
asyncGeneratorStep
asyncToGenerator.js:3
_throw
asyncToGenerator.js:29

enter image description here

Code

Book.tsx

import { throttle } from 'lodash';
import * as React from 'react';
import { AutoWidthPdf } from '../shared/AutoWidthPdf';
import BookCommandPanel from '../shared/BookCommandPanel';
import BookTextPath from '../static/pdf/sde.pdf';
import './Book.css';

const DEFAULT_WIDTH = 140;

class Book extends React.Component {
  setDivSizeThrottleable: () => void;
  pdfWrapper: HTMLDivElement | null = null;
  isComponentMounted: boolean = false;
  state = {
    hidden: true,
    pdfWidth: DEFAULT_WIDTH,
  };

  constructor(props: any) {
    super(props);
    this.setDivSizeThrottleable = throttle(
      () => {
        if (this.isComponentMounted) {
          this.setState({
            pdfWidth: this.pdfWrapper!.getBoundingClientRect().width - 5,
          });
        }
      },
      500,
    );
  }

  componentDidMount = () => {
    this.isComponentMounted = true;
    this.setDivSizeThrottleable();
    window.addEventListener("resize", this.setDivSizeThrottleable);
  };

  componentWillUnmount = () => {
    this.isComponentMounted = false;
    window.removeEventListener("resize", this.setDivSizeThrottleable);
  };

  render = () => (
    <div className="Book">
      { this.state.hidden && <div className="Book__LoadNotification centered">Book is being loaded...</div> }

      <div className={this.getPdfContentContainerClassName()}>
        <BookCommandPanel
          bookTextPath={BookTextPath}
          />

        <div className="Book__PdfContent" ref={ref => this.pdfWrapper = ref}>
          <AutoWidthPdf
            file={BookTextPath}
            width={this.state.pdfWidth}
            onLoadSuccess={(_: any) => this.onDocumentComplete()}
            />
        </div>

        <BookCommandPanel
          bookTextPath={BookTextPath}
          />
      </div>
    </div>
  );

  getPdfContentContainerClassName = () => this.state.hidden ? 'hidden' : '';

  onDocumentComplete = () => {
    try {
      this.setState({ hidden: false });
      this.setDivSizeThrottleable();
    } catch (caughtError) {
      console.warn({ caughtError });
    }
  };
}

export default Book;

AutoWidthPdf.tsx

import * as React from 'react';
import { Document, Page, pdfjs } from 'react-pdf';

pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.js`;

interface IProps {
  file: string;
  width: number;
  onLoadSuccess: (pdf: any) => void;
}
export class AutoWidthPdf extends React.Component<IProps> {
  render = () => (
    <Document
      file={this.props.file}
      onLoadSuccess={(_: any) => this.props.onLoadSuccess(_)}
      >
      <Page
        pageNumber={1}
        width={this.props.width}
        />
    </Document>
  );
}

Update 1: Cancel throttleable function (still no luck)

const DEFAULT_WIDTH = 140;

class Book extends React.Component {
  setDivSizeThrottleable: ((() => void) & Cancelable) | undefined;
  pdfWrapper: HTMLDivElement | null = null;
  state = {
    hidden: true,
    pdfWidth: DEFAULT_WIDTH,
  };

  componentDidMount = () => {
    this.setDivSizeThrottleable = throttle(
      () => {
        this.setState({
          pdfWidth: this.pdfWrapper!.getBoundingClientRect().width - 5,
        });
      },
      500,
    );

    this.setDivSizeThrottleable();
    window.addEventListener("resize", this.setDivSizeThrottleable);
  };

  componentWillUnmount = () => {
    window.removeEventListener("resize", this.setDivSizeThrottleable!);
    this.setDivSizeThrottleable!.cancel();
    this.setDivSizeThrottleable = undefined;
  };

  render = () => (
    <div className="Book">
      { this.state.hidden && <div className="Book__LoadNotification centered">Book is being loaded...</div> }

      <div className={this.getPdfContentContainerClassName()}>
        <BookCommandPanel
          BookTextPath={BookTextPath}
          />

        <div className="Book__PdfContent" ref={ref => this.pdfWrapper = ref}>
          <AutoWidthPdf
            file={BookTextPath}
            width={this.state.pdfWidth}
            onLoadSuccess={(_: any) => this.onDocumentComplete()}
            />
        </div>

        <BookCommandPanel
          BookTextPath={BookTextPath}
          />
      </div>
    </div>
  );

  getPdfContentContainerClassName = () => this.state.hidden ? 'hidden' : '';

  onDocumentComplete = () => {
    try {
      this.setState({ hidden: false });
      this.setDivSizeThrottleable!();
    } catch (caughtError) {
      console.warn({ caughtError });
    }
  };
}

export default Book;

Solution

Here is a React Hooks specific solution for

Error

Warning: Can’t perform a React state update on an unmounted component.

Solution

You can declare let isMounted = true inside useEffect, which will be changed in the cleanup callback, as soon as the component is unmounted. Before state updates, you now check this variable conditionally:

useEffect(() => {
  let isMounted = true;               // note mutable flag
  someAsyncOperation().then(data => {
    if (isMounted) setState(data);    // add conditional check
  })
  return () => { isMounted = false }; // cleanup toggles value, if unmounted
}, []);                               // adjust dependencies to your needs

_x000D_

_x000D_

const Parent = () => {
  const [mounted, setMounted] = useState(true);
  return (
    <div>
      Parent:
      <button onClick={() => setMounted(!mounted)}>
        {mounted ? "Unmount" : "Mount"} Child
      </button>
      {mounted && <Child />}
      <p>
        Unmount Child, while it is still loading. It won't set state later on,
        so no error is triggered.
      </p>
    </div>
  );
};

const Child = () => {
  const [state, setState] = useState("loading (4 sec)...");
  useEffect(() => {
    let isMounted = true;
    fetchData();
    return () => {
      isMounted = false;
    };

    // simulate some Web API fetching
    function fetchData() {
      setTimeout(() => {
        // drop "if (isMounted)" to trigger error again 
        // (take IDE, doesn't work with stack snippet)
        if (isMounted) setState("data fetched")
        else console.log("aborted setState on unmounted component")
      }, 4000);
    }
  }, []);

  return <div>Child: {state}</div>;
};

ReactDOM.render(<Parent />, document.getElementById("root"));

_x000D_

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef } = React</script>

_x000D_

_x000D_

_x000D_

Extension: Custom useAsync Hook

We can encapsulate all the boilerplate into a custom Hook, that automatically aborts async functions in case the component unmounts or dependency values have changed before:

function useAsync(asyncFn, onSuccess) {
  useEffect(() => {
    let isActive = true;
    asyncFn().then(data => {
      if (isActive) onSuccess(data);
    });
    return () => { isActive = false };
  }, [asyncFn, onSuccess]);
}

_x000D_

_x000D_

// custom Hook for automatic abortion on unmount or dependency change
// You might add onFailure for promise errors as well.
function useAsync(asyncFn, onSuccess) {
  useEffect(() => {
    let isActive = true;
    asyncFn().then(data => {
      if (isActive) onSuccess(data)
      else console.log("aborted setState on unmounted component")
    });
    return () => {
      isActive = false;
    };
  }, [asyncFn, onSuccess]);
}

const Child = () => {
  const [state, setState] = useState("loading (4 sec)...");
  useAsync(simulateFetchData, setState);
  return <div>Child: {state}</div>;
};

const Parent = () => {
  const [mounted, setMounted] = useState(true);
  return (
    <div>
      Parent:
      <button onClick={() => setMounted(!mounted)}>
        {mounted ? "Unmount" : "Mount"} Child
      </button>
      {mounted && <Child />}
      <p>
        Unmount Child, while it is still loading. It won't set state later on,
        so no error is triggered.
      </p>
    </div>
  );
};

const simulateFetchData = () => new Promise(
  resolve => setTimeout(() => resolve("data fetched"), 4000));

ReactDOM.render(<Parent />, document.getElementById("root"));

_x000D_

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef } = React</script>

_x000D_

_x000D_

_x000D_

More on effect cleanups: Overreacted: A Complete Guide to useEffect

Source: StackOverflow.com

Leave a Reply

Your email address will not be published. Required fields are marked *