My use case is as follows: I build a web application, and I use react-router@6
to build the application's routes. I'm using the RouterProvider
from react-router-dom
. Then, I configured some routes, together with nested routes also. Each route, including nested routes, is lazy loaded. The problem I had by now is, when I navigate from page to another, the application screen flushes. To solve it, I used this technique: https://github.com/HanMoeHtet/route-level-code-split
In short, I use React.Suspense
with a fallback
value of the current rendered page. This way, when I navigate to other page (which is lazy loaded), the fallback element is the same page I navigated from, and this is how the screen does not flushes anymore.
I'm going to provide my whole code here, but most code blocks are going to be duplicated from https://github.com/HanMoeHtet/route-level-code-split So you can look there instead if you want
Here is my entry file of the application, index.tsx
:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import store from './store/app';
import './i18n/config';
import './styles/custom.scss';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<Provider store={store}>
<App />
</Provider>,
);
export default root;
Then, my App.tsx
file is (I removed some irrelevant logic..):
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import axios from 'axios';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { IAutoAuthResponseData } from '@exlint.io/common';
import { backendApi, cliBackendApi } from './utils/http';
import type { IAuthPayload } from './store/interfaces/auth';
import type { AppState } from './store/app';
import { authActions } from './store/reducers/auth';
import AppView from './App.view';
interface IPropsFromState {
readonly isAuthenticated: boolean | null;
}
interface IPropsFromDispatch {
readonly auth: (loginPayload: IAuthPayload) => PayloadAction<IAuthPayload>;
readonly setUnauthenticated: () => PayloadAction;
}
interface IProps extends IPropsFromState, IPropsFromDispatch {}
const App: React.FC<IProps> = (props: React.PropsWithChildren<IProps>) => {
return <AppView isAuthenticated={true} />;
};
App.displayName = 'App';
App.defaultProps = {};
const mapStateToProps = (state: AppState) => {
return {
isAuthenticated: state.auth.isAuthenticated,
};
};
export default connect(mapStateToProps, {
auth: authActions.auth,
setUnauthenticated: authActions.setUnauthenticated,
})(React.memo(App));
And my App.view.tsx
file is:
import React, { useMemo } from 'react';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import RouterBuilder from './App.router';
interface IProps {
readonly isAuthenticated: boolean | null;
}
const AppView: React.FC<IProps> = (props: React.PropsWithChildren<IProps>) => {
const routes = useMemo(() => {
return RouterBuilder(props.isAuthenticated);
}, [props.isAuthenticated]);
return <RouterProvider router={createBrowserRouter(routes)} fallbackElement={null} />;
};
AppView.displayName = 'AppView';
AppView.defaultProps = {};
export default React.memo(AppView);
My App.router.tsx
file is:
import React from 'react';
import { Navigate, type RouteObject } from 'react-router-dom';
import AppLayout from './App.layout';
import { startProgress } from './services/progress-bar';
import { preloader } from './utils/http-backend';
const Auth = React.lazy(() => import('./pages/Auth'));
const ExternalAuthRedirect = React.lazy(() => import('./pages/ExternalAuthRedirect'));
const AccountSettings = React.lazy(() => import('./pages/AccountSettings'));
const CliAuth = React.lazy(() => import('./pages/CliAuth'));
const CliAuthenticated = React.lazy(() => import('./pages/CliAuthenticated'));
const NotFound = React.lazy(() => import('./pages/NotFound'));
const Account = React.lazy(() => import('@/containers/AccountSettings/Account'));
const SecretManagement = React.lazy(() => import('@/containers/AccountSettings/SecretManagement'));
const RouterBuilder = (isAuthenticated: boolean | null) => {
const unAuthorizedRoutes: RouteObject[] = [
{
path: '',
element: <Auth />,
},
{
path: 'auth',
element: <Auth />,
},
{
path: 'external-auth-redirect',
element: <ExternalAuthRedirect />,
},
];
const authorizedRoutes: RouteObject[] = [
{
path: 'account-settings',
element: <AccountSettings />,
children: [
{
path: '',
element: <Navigate to="account" replace />,
},
{
path: 'account',
element: <Account />,
},
{
path: 'secret-management',
element: <SecretManagement />,
loader: async () => {
startProgress();
await preloader('/user/secrets');
return null;
},
},
{
path: 'secret-management/new',
element: <NewSecret />,
},
{
path: 'secret-management/*',
element: <Navigate to="secret-management" replace />,
},
{
path: '*',
element: <Navigate to="account" replace />,
},
],
},
];
const generalRoutes: RouteObject[] = [
{
path: 'cli-auth',
element: <CliAuth />,
},
{
path: 'cli-authenticated',
element: <CliAuthenticated />,
},
{
path: 'not-found',
element: <NotFound />,
},
{
path: '*',
element: isAuthenticated === null ? null : <NotFound />,
},
];
const routes = [
{
element: <AppLayout />,
children: [...(isAuthenticated ? authorizedRoutes : unAuthorizedRoutes), ...generalRoutes],
},
];
return routes;
};
export default RouterBuilder;
This is my App.layout.tsx
file:
import React from 'react';
import { Outlet } from 'react-router-dom';
import EDNotification from '@/ui/EDNotification';
import FallbackProvider from './helpers/FallbackProvider';
interface IProps {}
const AppLayout: React.FC<IProps> = () => {
return (
<FallbackProvider>
<Outlet />
<div id="backdrop-root" />
<div id="overlay-root" />
<EDNotification />
</FallbackProvider>
);
};
AppLayout.displayName = 'AppLayout';
AppLayout.defaultProps = {};
export default React.memo(AppLayout);
This my FallbackProvider.tsx
file:
import React, { Suspense, useCallback, useMemo, useState } from 'react';
import { FallbackContext } from './context/fallback';
import type { FallbackType } from './interfaces/types';
interface IProps {}
const FabllbackProvider: React.FC<IProps> = (props: React.PropsWithChildren<IProps>) => {
const [fallbackState, setFallbackState] = useState<FallbackType>(null);
const updateFallback = useCallback((fallback: FallbackType) => {
setFallbackState(() => fallback);
}, []);
const renderChildren = useMemo(() => {
return props.children;
}, [props.children]);
return (
<FallbackContext.Provider value={{ updateFallback }}>
<Suspense fallback={fallbackState}>{renderChildren}</Suspense>
</FallbackContext.Provider>
);
};
FabllbackProvider.displayName = 'FabllbackProvider';
FabllbackProvider.defaultProps = {};
export default React.memo(FabllbackProvider);
And this is the related context:
import { createContext } from 'react';
import type { FallbackContextType } from '../interfaces/types';
export const FallbackContext = createContext<FallbackContextType>({
updateFallback: () => {
return;
},
});
Then I have this Page.tsx
wrapper:
import React, { useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { usePage } from '@/hooks/use-page';
import { endProgress, startProgress } from '@/services/progress-bar';
interface IProps {}
const Page: React.FC<IProps> = (props: React.PropsWithChildren<IProps>) => {
const { onLoad } = usePage();
const location = useLocation();
const render = useMemo(() => {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{props.children}</>;
}, [props.children]);
useEffect(() => {
onLoad(render);
}, [onLoad, render]);
useEffect(() => {
endProgress();
return () => startProgress();
}, [location]);
return render;
};
Page.displayName = 'Page';
Page.defaultProps = {};
export default Page;
and the use-page.ts
hook is:
import { useCallback, useContext } from 'react';
import type { FallbackType } from '../helpers/FallbackProvider/interfaces/types';
import { FallbackContext } from '../helpers/FallbackProvider/context/fallback';
export const usePage = () => {
const { updateFallback } = useContext(FallbackContext);
const onLoad = useCallback(
(component: FallbackType | undefined) => {
if (component === undefined) {
component = null;
}
updateFallback(component);
},
[updateFallback],
);
return { onLoad };
};
Now, for each page in App.router.tsx
(each page loaded from pages
folder only and not components
folder!), I do this thing:
import React from 'react';
import Auth from '@/containers/Auth';
import Page from '@/helpers/Page';
interface IProps {}
const AuthPage: React.FC<IProps> = () => {
return (
<Page>
<Auth />
</Page>
);
};
AuthPage.displayName = 'AuthPage';
AuthPage.defaultProps = {};
export default AuthPage;
And finally, this is my progress-bar.ts
service:
import nProgress from 'nprogress';
nProgress.configure({
showSpinner: false,
});
export const startProgress = () => {
nProgress.start();
};
export const endProgress = () => {
nProgress.done();
};
Then I try to load my website but I get this error:
Unexpected Application Error!
A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator. To fix, updates that suspend should be wrapped with startTransition.
throwException@http://localhost:8080/node_modules/.vite/deps/chunk-LCEHA6P5.js?v=0485a108:14217:43
handleError@http://localhost:8080/node_modules/.vite/deps/chunk-LCEHA6P5.js?v=0485a108:19030:29
renderRootSync@http://localhost:8080/node_modules/.vite/deps/chunk-LCEHA6P5.js?v=0485a108:19115:26
recoverFromConcurrentError@http://localhost:8080/node_modules/.vite/deps/chunk-LCEHA6P5.js?v=0485a108:18732:42
performSyncWorkOnRoot@http://localhost:8080/node_modules/.vite/deps/chunk-LCEHA6P5.js?v=0485a108:18875:28
flushSyncCallbacks@http://localhost:8080/node_modules/.vite/deps/chunk-LCEHA6P5.js?v=0485a108:9135:30
../../node_modules/.pnpm/[email protected][email protected]/node_modules/react-dom/cjs/react-dom.development.js/ensureRootIsScheduled/<@http://localhost:8080/node_modules/.vite/deps/chunk-LCEHA6P5.js?v=0485a108:18623:21
💿 Hey developer 👋
You can provide a way better UX than this when your app throws errors by providing your own errorElement props on <Route>
This happens every time I navigate, to any URL in website.
I started using startTransition
functionb in several places related with FallbackProvider.tsx
file, on this row: <Suspense fallback={fallbackState}>{renderChildren}</Suspense>
.
So that's what I tried:
In FallbackProvider.tsx
I changed the updateFallback
function to:
const updateFallback = useCallback((fallback: FallbackType) => {
startTransition(() => {
setFallbackState(() => fallback);
});
}, []);
In Page.tsx
I modified the 2 useEffect
to:
useEffect(() => {
startTransition(() => {
onLoad(render);
});
}, [onLoad, render]);
useEffect(() => {
startTransition(() => {
endProgress();
});
return () => startProgress();
}, [location]);
And in use-page.ts
file I modified the onLoad
function to:
const onLoad = useCallback(
(component: FallbackType | undefined) => {
if (component === undefined) {
component = null;
}
startTransition(() => {
updateFallback(component!);
});
},
[updateFallback],
);
These changes did not help me, and the error is the same.
I changed this line of code in use-page.ts
file:
updateFallback(component);
to return null;
and the issue does not occur anymore. But of course.. now I canceled the point of the code (now when I return null, there won't be the fallback I want - the renderer page).
I decided on just using this flow:
Every route has loader
key where I force the lazy loading:
loader: async () => {
startProgress();
await import('./pages/AccountSettings');
endProgress();
return null;
},
This is how each route looks like now. I did it also to nested routes.
Regarding the startProgress
and endProgress
, I'm using the n-progress
package and it seems to handle multiple calls perfect. By that I mean that the doing startProgress
and endProgress
in a parents components and its children still works, though you might think it can lead to issues.
I removed any other code block. You can see the full code here: https://github.com/Exlint/dashboard