In an "offline-first" app (which optimistically updates local state and gracefully handles errors by rolling back local state changes, for example), some features may still require internet connectivity.
In one case, I require internet connectivity to use a feature or even mount a component. If the user has no internet access (presume I can reliably detect this), I throw an error if the component is mounted.
I catch this error using an error boundary component that wraps react-error-boundary. Yes, in my example, I could simply check if the user is online in the relevant component and render the fallback if so, but there are other factors not described that should not be relevant to the minimal reproducible example.
This is my wrapper for react-error-boundary's ErrorBoundary component:
interface ErrorBoundaryProps extends React.PropsWithChildren {
message?: string
}
interface ErrorFallbackProps extends ErrorBoundaryProps {
onRetry: () => void
}
const ErrorFallback = memo((props: ErrorFallbackProps) => {
const { message = "Something went wrong", onRetry } = props
const { resetBoundary } = useErrorBoundary()
const handleRetry = useCallback(() => {
resetBoundary()
onRetry()
}, [onRetry, resetBoundary])
return (
<div>
<p>{message}</p>
<Button
aria-label="Try again"
onClick={handleRetry}
>
<RetryIcon />
</Button>
</div>
)
})
ErrorFallback.displayName = "ErrorFallback"
export const CustomErrorBoundary = memo((props: ErrorBoundaryProps) => {
const { message } = props
const [attempt, setAttempt] = React.useState(0)
const handleRetry = useCallback(() => {
// `attempt` is incremented to remount all children
setAttempt((prev) => prev + 1)
}, [])
return (
<ErrorBoundary
// This should ensure that every retry results in a new mount
key={attempt}
fallback={<ErrorFallback message={message} onRetry={handleRetry} />}
onError={console.error}
>
{props.children}
</ErrorBoundary>
)
})
Let's say I have a component like this:
const BigOnlineFeature = memo(() => {
if (!userIsOnline) throw new Error("This component requires internet connectivity.")
const [success, setSuccess] = useState<boolean | null>(null)
useEffect(() => {
// NOTE: Exceptions in unawaited promises should not trigger the error boundary
performSomeNetworkCall().then(() => {
setSuccess(true)
}).catch(() => {
setSuccess(false)
})
}, [])
if (success === undefined) return <p>Pending...</p>
if (success) return <p>Success!</p>
return <p>Failed!</p>
})
If I have an app like this:
const App = () => {
return (
<CustomErrorBoundary message="This feature requires internet connectivity.">
<RequiresInternetConnectivity />
</CustomErrorBoundary>
)
}
then I do this procedure to test:
App
component...then the final result is as expected.
Now, there are two target audiences of this app:
Due to the large size of the actual RequiresInternetConnectivity
component and everything it imports, I have made it a lazy component to improve the initial load of the app and lazy-load the code required for this feature:
const BigOnlineFeature = React.lazy(() => import("./path/to/BigOnlineFeature")
const App = () => {
return (
<CustomErrorBoundary
message="This feature requires internet connectivity. You are currently offline."
>
<Suspense fallback={<Spinner />}>
<BigOnlineFeature />
</Suspense>
</CustomErrorBoundary>
)
}
This is the error that happens as expected when mounting the component while offline:
Uncaught TypeError: error loading dynamically imported module: http://localhost:3000/src/onlineOnly/BigOnlineFeature.tsx
However, when I disable the offline mode simulation and click the retry button, the same error is thrown again. If I remain online the whole time, it's loaded and rendered correctly.
It seems like React.lazy
caches the result of the first import. Because the arrow function in React.lazy
threw an error the first time, it will throw it every time.
Probably relevant observations:
Is there a way for me to achieve what I'm trying to do: Lazily loading the code for a feature that requires internet connectivity, while still allowing the component that defines the React.lazy
component to be used while offline?
Edit: Even if importing the lazy component within a wrapper, the result is the same (note that the BigOnlienFeatureWrapper
below should be remounted on each retry because of the attempt
key):
const BigOnlineFeatureWrapper = React.memo(() => {
const BigOnlineFeature = React.lazy(() => import("./path/to/BigOnlineFeature"))
return <BigOnlineFeature />
})
Edit 2: There seems to be an ongoing discussion about catching these errors using error boundaries, but the only viable option seems to be to refresh the window, which is not really how this app works. If you reload the window, you reload a bunch of stuff that you don't want to reload. Navigating to the exact same UI state would also add a ton of complexity.
You've discerned correctly that React.lazy
caches the result:
Both the returned Promise and the Promise’s resolved value will be cached, so React will not call
load
more than once. If the Promise rejects, React will throw the rejection reason for the nearest Error Boundary to handle.
The only way to get the behavior you want is to write your own version of React.lazy
(or to use a library like https://loadable-components.com/ which, if I am reading its code correctly, will attempt to reload your component the next time you mount it if your component throws an error while loading).