I've been facing a nasty bug with React Router (6.4.1) and our own custom provider after moving to React 18.
I have used an implementation inspired by this post - https://medium.com/geekculture/how-to-conditionally-render-react-ui-based-on-user-permissions-7b9a1c73ffe2.
A brief design looks something like this:
This setup worked just fine with React 17. However, after moving to React 18. The Provider (and resulting the component that utilizes the provider causes an infinite render loop when a component state is updated elsewhere.
https://codesandbox.io/s/priceless-fog-ck0xq4?file=/src/index.tsx this is the sandbox solution.
The re-render issue is so bad that it won't just load in editor mode. You have to go the preview mode of this app - https://ck0xq4.csb.app/page/12324 URL (preview mode) and click on the toggle button and observe the Console logs.
ADVICE - Because of the infinite render, the codesandbox may crash your browser / tab. Apologies in advance for that.
The moment it re-renders the conditional elements, the app goes in render loop.
I tried pinpointing the exact issue, I figured out that whenever I use useParams
from react-router-dom
, the Provider goes into an infinite loop.
There is a chance that the provider I have configured is completely wrong but I just can't get a hang of it.
Relevant code:
index.tsx
import { StrictMode } from "react";
import * as ReactDOMClient from "react-dom/client";
import { createBrowserRouter, RouterProvider, Route } from "react-router-dom";
import App from "./App";
import SubPage from "./SubPage";
const rootElement = document.getElementById("root");
const root = ReactDOMClient.createRoot(rootElement);
const router = createBrowserRouter([
{
path: "/",
element: <App />,
children: [
{
path: "page/:pageId",
element: <SubPage />
}
]
}
]);
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);
App.tsx
import { useState } from "react";
import { Outlet } from "react-router-dom";
import ActionProvider from "./providers/PermissionsProvider";
import "./styles.css";
export type Action = string;
export type UserActions = {
groupId: string;
roleName: string;
actions: Action[];
};
export default function App() {
const workspaceActions: UserActions = {
groupId: "072f9aa7-1268-4b44-9f62-70cb43c38a59",
roleName: "Privileged Member",
actions: [
"data.list",
"data.read",
"data.download",
"data.archive",
"data.create",
"data.delete",
"data.upload"
]
};
const fetchAvailableActions = () => (action: string) => {
const permissions = workspaceActions.actions;
return permissions.includes(action);
};
const [toggleMenu, setToggleMenu] = useState(false);
return (
<div>
<ActionProvider fetchActions={fetchAvailableActions()}>
{toggleMenu ? <div>sidebar</div> : <div>navbar</div>}
<div className="App">
<Outlet />
</div>
</ActionProvider>
<button
onClick={() => {
setToggleMenu(!toggleMenu);
}}
type="button"
>
toggle
</button>
</div>
);
}
SubPage.tsx
import { useParams } from "react-router-dom";
import Restricted from "./providers/Restricted";
const SubPage = () => {
const { pageId } = useParams();
return (
<Restricted to="data.list">
<p>Hello from the subpage {pageId}</p>
<p>
Open Developer Toolbar (F12), navigate to console. Click on toggle
button to replicate the issue. Observe the console logs.
</p>
<h1>
Please be aware that the browser may crash because of the issue I
explained. Advice is to open a separate browser instance which can be
closed easily.
</h1>
</Restricted>
);
};
export default SubPage;
ActionContext.ts
import React from "react";
import { Action } from "../models/providers/Permissions";
type ActionContextType = {
isAllowedTo: (action: Action) => Promise<boolean>;
};
// Default behaviour for the Permission Provider Context
// i.e. if for whatever reason the consumer is used outside of a provider.
// The permission will not be granted unless a provider says otherwise
const defaultBehaviour: ActionContextType = {
isAllowedTo: () => Promise.resolve(false)
};
// Create the context
const ActionContext = React.createContext<ActionContextType>(defaultBehaviour);
export default ActionContext;
PermissionsProvider.tsx
import React, { PropsWithChildren } from "react";
import { Action } from "../models/providers/Permissions";
import ActionContext from "./ActionContext";
type Props = {
fetchActions: (p: Action) => boolean;
};
type ActionCache = {
[key: string]: boolean;
};
// This provider is intended to be surrounding the whole application.
// It should receive the users permissions as parameter
const ActionProvider: React.FC<PropsWithChildren<Props>> = ({
fetchActions,
children
}) => {
console.log("in action provider");
const cache: ActionCache = {};
// Creates a method that returns whether the requested permission is available in the list of permissions
// passed as parameter
const isAllowedTo = async (action: Action): Promise<boolean> => {
console.log("isAllowedTo");
if (Object.keys(cache).includes(action)) {
return cache[action];
}
const isAllowed = await fetchActions(action);
cache[action] = isAllowed;
return isAllowed;
};
// This component will render its children wrapped around a PermissionContext's provider whose
// value is set to the method defined above
// eslint-disable-next-line react/jsx-no-constructed-context-values
return (
<ActionContext.Provider value={{ isAllowedTo }}>
{children}
</ActionContext.Provider>
);
};
export default ActionProvider;
Restricted.tsx
/* eslint-disable react/require-default-props */
/* eslint-disable react/jsx-no-useless-fragment */
import React, { PropsWithChildren } from "react";
import { Action } from "../models/providers/Permissions";
import usePermission from "./usePermission";
type Props = {
to: Action;
fallback?: JSX.Element | string;
};
// This component is meant to be used everywhere a restriction based on user permission is needed
const Restricted: React.FC<PropsWithChildren<Props>> = ({
to,
fallback,
children
}) => {
console.log("in restricted");
// We "connect" to the provider thanks to the PermissionContext
const allowed = usePermission(to);
// If the user has that permission, render the children
if (allowed) {
return <>{children}</>;
}
// Otherwise, render the fallback
return <>{fallback}</>;
};
export default Restricted;
usePermission.ts
import { useContext, useState } from "react";
import { Action } from "../models/providers/Permissions";
import ActionContext from "./ActionContext";
const useAction = (action: Action) => {
const [allowed, setAllowed] = useState<boolean>();
const { isAllowedTo } = useContext(ActionContext);
isAllowedTo(action).then((_allowed) => {
setAllowed(_allowed);
});
return allowed || false;
};
export default useAction;
The issue I see in the code is with the usePermission
/useAction
hook, it is unconditionally enqueueing state updates. isAllowedTo
is unconditionally called each render cycle as an unintentional side-effect and enqueues a state update which triggers another render cycle.
usePermission
import { useContext, useState } from "react";
import { Action } from "../models/providers/Permissions";
import ActionContext from "./ActionContext";
const useAction = (action: Action) => {
const [allowed, setAllowed] = useState<boolean>();
const { isAllowedTo } = useContext(ActionContext);
isAllowedTo(action).then((_allowed) => { // <-- unintentional side-effect
setAllowed(_allowed); // <-- update triggers rerender
});
return allowed || false;
};
export default useAction;
TBH I don't see how this wasn't an issue in react@17
.
Move the unintentional side-effect into a useEffect
hook so it is an intentional side-effect.
Example:
const useAction = (action: Action) => {
const [allowed, setAllowed] = useState<boolean>(false);
const { isAllowedTo } = useContext(ActionContext);
useEffect(() => {
isAllowedTo(action).then((_allowed) => {
setAllowed(_allowed);
});
}, [action]);
return allowed;
};
Any eslinters with the React hooks enabled by complain about a missing isAllowedTo
dependency, so to resolve this you'll likely want to provide a stable isAllowedTo
callback reference.
PermissionsProvider
const ActionProvider: React.FC<PropsWithChildren<Props>> = ({
fetchActions,
children
}) => {
console.log("in action provider");
const cache: ActionCache = {};
// Creates a method that returns whether the requested permission is available in the list of permissions
// passed as parameter
const isAllowedTo = useCallback(async (action: Action): Promise<boolean> => {
console.log("isAllowedTo");
if (Object.keys(cache).includes(action)) {
return cache[action];
}
const isAllowed = await fetchActions(action);
cache[action] = isAllowed;
return isAllowed;
}, [cache, fetchActions]);
// This component will render its children wrapped around a PermissionContext's provider whose
// value is set to the method defined above
// eslint-disable-next-line react/jsx-no-constructed-context-values
return (
<ActionContext.Provider value={{ isAllowedTo }}>
{children}
</ActionContext.Provider>
);
};
isAllowedTo
can now be added to the dependency array.
const useAction = (action: Action) => {
const [allowed, setAllowed] = useState<boolean>(false);
const { isAllowedTo } = useContext(ActionContext);
useEffect(() => {
isAllowedTo(action).then((_allowed) => {
setAllowed(_allowed);
});
}, [action, isAllowedTo]);
return allowed;
};