react: React.lazy does not allow retrying a rejected promise

Do you want to request a feature or report a bug?

It can be seen as a feature or a bug, depending on angle. Let’s say it’s an enhancement to how lazy works.

What is the current behavior?

When using React.lazy, if the given promise rejects while trying to asynchronously load a component, it’s no longer possible to retry loading the component chunk because lazy internally caches the promise rejection.

If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem. Your bug will get fixed much faster if we can run your code and it doesn’t have dependencies other than React. Paste the link to your JSFiddle (https://jsfiddle.net/Luktwrdm/) or CodeSandbox (https://codesandbox.io/s/new) example below:

This does not seem to work great in CodeSandbox because it’s using service workers, which get in the way when simulating offline mode, yet this small app illustrates the issue: https://codesandbox.io/s/v8921j642l

What is the expected behavior?

A promise rejection should not be cached by lazy and another attempt to render the component should call the function again, giving it the chance to return a new promise.

Which versions of React, and which browser / OS are affected by this issue? Did this work in previous versions of React?

AFAIK all version of React that include lazy.

About this issue

  • Original URL
  • State: closed
  • Created 6 years ago
  • Reactions: 19
  • Comments: 17 (4 by maintainers)

Most upvoted comments

This issue (feature request) is still valid. I think that retrying should have first-class support.

Handling lazy loading was so much easier with use of react-loadable (unfortunately it doesn’t look like its maintained) it shame that its done so poorly in main library. Also it looks like everybody tries to do automatic retries and what about showing user some message and for example retrying on user action like clicking retry button?

Edit: I have found a way how to handle errors with retrying on user interaction if anyone needs that:

import * as React from 'react';

export default function LazyLoaderFactory<T = any>(
  promise: () => Promise<{ default: React.ComponentType<any> }>,
  Loader = () => <>Loading...</>,
  ErrorRetryView = ({ retry }) => <button onClick={retry}>Module loading error. Try again.</button>,
) {
  function LazyLoader(props: T) {
    const [ loading, setLoading ] = React.useState<boolean>(true);
    const retry = React.useCallback(() => setLoading(true), []);
    const Lazy = React.useMemo(() => React.lazy(() => promise().catch(() => {
      setLoading(false);
      return { default: () => <ErrorRetryView retry={retry}/> };
    })), [promise, loading]);
    return <React.Suspense fallback={<Loader/>}><Lazy {...props}/></React.Suspense>;
  }

  (LazyLoader as any).displayName = `LazyLoader`;

  return LazyLoader as React.ComponentType<T>;
}

FWIW, I ran into this same problem, and found this issue while doing research as to who was caching the promise (thanks for filing it!). I found a workaround if you still have to make it work until this is properly solved here.

Codesandbox is here: https://codesandbox.io/s/1rvm45z3j3

Basically I created this function, which creates a new React.lazy component whenever the import promise rejects, and assigns it back to the component variable:

let Other;

function retryableLazy(lazyImport, setComponent) {
  setComponent(
    React.lazy(() =>
      lazyImport().catch(err => {
        retryableLazy(lazyImport, setComponent);
        throw err;
      })
    )
  );
}

retryableLazy(
  () => import("./Other"),
  comp => {
    Other = comp;
  }
);

Another important aspect is that the error boundary of your component should be using something like render props, in order to use the new value that the variable references at any point in time (otherwise it will always use the first value assigned to Other and keep using it forever):

<ErrorBoundary>
  {() => (
    <Suspense fallback={<div>Loading...</div>}>
      <Other />
    </Suspense>
  )}
</ErrorBoundary>

Hope this helps at least make it work while this is solved!

If there was an update — it would be on this issue. 😃

You can help drive it by submitting a failing test case, with or without a fix. Here’s an example of me changing something in React.lazy a few days ago, might help: https://github.com/facebook/react/pull/14626.

@threepointone that would immediately retry loading a failed module until it eventually fails and gives up, which could address part of the problem.

The part we can’t currently address unless we change lazy implementation is the scenario where we want to retry loading a previously failed async module load after the initial attempt(s) gave up.

Imagine an app code-split by route:

  1. The user loads the initial route, navigates around while loading new routes on demand and then eventually goes offline, or has connectivity issues.
  2. Any new route requested during this period will fail to load (which is expected) and we want to be able to gracefully show an error letting the user know we couldn’t load it right now.
  3. The user is free to continue using the application and navigate back to the routes that have already been loaded.
  4. At a later time, if the user goes back to the previously failed route, we want to retry loading the module from the network and render it if successful.

Since lazy is caching a failed promise, we can’t do the 2nd part of 2 in a timely fashion or do 4 at all right now. Changing lazy to cache only fulfilled promises and forget rejected ones would allow us to support this use case, which seems something we would like React to facilitate.

This is applicable to any lazily loaded component, not only at “route-based” split points.

For anyone else coming across this issue; I’ve gone for a slightly different workaround:

<ErrorBoundary>
  {() => (
    <Suspense fallback={<div>Loading component...</div>}>
      {React.createElement(
        React.lazy(() => import("./my/Component")),
        {  aProp: "aValue"  }
      )}
    </Suspense>
  )}
</ErrorBoundary>;

This is re-rendered by a retry button in the ErrorBoundary that changes the ErrorBoundary state.

React.lazy accepts any async function that returns a module, so you could do something like this -

const Something = React.lazy(async () => {
  let mod;
  while(!mod){ 
    try{
      mod = await import('./something')
    }
    catch(err){}   
  }
  // this does infinite retries, 
  // you could modify it to do it only thrice, 
  // or add backoff/network detection logic, etc 
  return mod 
})

(I haven’t tested the above, but I think it should work?)

Sorry for bringing back an old post, but I think I’ve found a way to reproduce this. In my casse, this error only happens when redeploying. In my case i’m using Vite to bundle a React SPA with React Router and a global app-level error boundary.

Say you have a user browsing your deployed app, and you deploy a new version that changes something in a component that is being dynamically loaded. Well, when the deploy finishes, your old version of the dynamically loaded modules would no longer be available because of cache busting, but the new ones will be.

So when the user, that had the tab open with the same url all this time goes to browse another route, the module loading system will crash.

I implemented a very very hacky workaround for this like this (TS Code):

const MODULE_IMPORT_ERROR = 'Failed to fetch dynamically imported module'

function handleError(error: Error) {
  // eslint-disable-next-line
  console.error(error)
  // if the error starts with this message, reload the whole app
  if (error.message.indexOf(MODULE_IMPORT_ERROR) === 0) {
    window.location.reload()
  }
}

export default function ErrorCatcher({ children }: React.PropsWithChildren<unknown>) {
  return (
    <ErrorBoundary FallbackComponent={ErrorMessage} onError={handleError}>
      {children}
    </ErrorBoundary>
  )
}