reactjstypescriptreact-hooksreact-routerreact-context

Custom Provider with React Router causes infinite render loop in React 18


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:

enter image description here

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;

Solution

  • Issue

    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.

    Solution

    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;
    };