1

Before Ask, I already:

  1. I understand StrictMode will make render -> unmount -> render, which will make my component renders twice
  2. I understand in StrictMode I need to use keep my component pure function to make it work
  3. Most related to my question is this. It's exactly the same problem I'm facing, but no answers I expected.

The Problem:

My function will make a network call with abortController.signal as useState. Upon unmounting, it'll abort the controller by useEffect.

But, when react render(0) -> unmount(0) -> render(1), the umount(0) actually read the state already from render(1), which lead to my render(1) got cleaned up, leading to unexpected result.

My expectation:

React's first umount(0) will see state from render(0) so my cleanup function works as expected.

Code:

import { Suspense, useEffect, useMemo, useState } from 'react';

// make promise suspendable
enum Status {
  Pending,
  Fulfilled,
  Rejected,
}

const Suspendable = <T,>(promise:Promise<T>): (() => T) => {
  let status: Status = Status.Pending;
  let error: Error;
  let result: T;
  const suspender = promise.then((r: T): void => {
      status = Status.Fulfilled;
      result = r;
  })
      .catch(err => {
          status = Status.Rejected;
          error = err;
      });

  return () => {
      switch(status) {
          case Status.Pending:
              throw suspender;
          case Status.Rejected:
              throw error;
          case Status.Fulfilled:
              return result;
          default:
              throw Error(`Unexpected arg ${status}`);
      }
  }
};

// make fetch a promise
const Req = (uri: string, config: RequestInit={}): Promise<string> =>   new Promise<string>((resolve, reject) => {
  fetch(uri, config)
      .then(resp => resp.text())
      .then(resolve)
      .catch(reject);
      // using this catch lead to page stuck at loading forever as promise never resolved
      // .catch(e => {
      //   if(e.name === "AbortError") {
      //     return;
      //   }
      //   return reject(e);
      // });
});

// do actual render content
const Renderer = ({ getResponse }: { getResponse: () => string }) => {
  console.log(`Renderer start`);
  const response = getResponse();
  console.log(`Renderer get response, return component`);
  return <pre>{response}</pre>;
}

// state interface
interface Result {
  suspendable: () => string;
  abortController: AbortController;
  retryKey: number;
}

const App = () => {
  const [controller, setResult] = useState<Result>((() => {
    const abortController = new AbortController();
    const retryKey = Math.floor(Math.random() * 100000);
    console.log(`create controller ${retryKey}`)
    return {
        abortController,
        retryKey,
        suspendable: Suspendable<string>(Req(`/`, { signal: abortController.signal }))
    };
  })());

  useEffect(() => {
    return () => {
      console.log(`clean up ${controller.retryKey}`);
      controller.abortController.abort();
    }
  })

  // ErrorBoundary wont help (
  return <>
  {/* <ErrorBoundary> */}
    <Suspense fallback={<p>Loading</p>}>
        <Renderer getResponse={controller.suspendable} />
    </Suspense>
    <p>-- Parent Node</p>
  {/* </ErrorBoundary> */}
</>;
}

export default App;

Expectation:

  1. StrictMode runs the first time, page error out, or catch by errorBoundary
  2. StrictMode unmount, useEffect runs cleanup, connection aborted
  3. StrictMode runs a second time, starts Suspense loading, and renders page content after fetch finished

Unexpected, actual Result:

StrictMode clean up my second state on the first render, making my second render aborted on the loading process.

Unexpceted logs:

create controller 8343  # first render
create controller 69996  # second render? no clean up called?
Renderer start
clean up 69996  # first unmount finally happend? but cleanup my second render?
Renderer start
Uncaught DOMException: The operation was aborted.   # expected
Renderer start
Uncaught DOMException: The operation was aborted.   # unexpected
clean up 69996  # again?

Version:

"react": "^18.2.0"


Edit:

the code from this works as:

  const hasUnmounted = useRef(false)

  useEffect(() => {
    return () => {
      if(hasUnmounted.current){
          // do my cleanup
      }
      hasUnmounted.current = true;
    }
  })

But,

  1. I don't understand why my old code can't work
  2. Why I need to manually check using ref.
TylerTemp
  • 970
  • 1
  • 9
  • 9

1 Answers1

0

I dont think react documented its strict mode correctly, but here is the way to avoid it without dirty hacking (without using a ref):

if you clean up something, then create it inside the same useEffect

to say, the code should be:

const [controller, setController] = useState<AbortController | null>(null);
useEffect(() => {
    setController(new AbortController());
    return () => {
        // don't assert this is non-null. 
        controller?.abort();
    }
}, []);

// Now listen to the controller changes
useEffect(() => {
    if(controller) {
        // do some setState to know it's loading/finished/error
        fetch(..., {signal: controller.signle});
    }
}, [controller]);

// this is the dirty part. Always deal with the not-created (yet) situation.
// or you have some other state to indicate, e.g. `isLoading`
if(controller === null) {  
    return <>loading...</>;
}

return <>Your logic here...</>;
TylerTemp
  • 970
  • 1
  • 9
  • 9