Search code examples
reactjstypescriptmaterial-uireact-router-dom

How to check if a route or its nested route is active?


For example there are the following routes:

<Routes>
  <Route path="admin">
    <Route path="users">
      <Route index element={<UserList />} />
      <Route path="create" element={<UserDetails />} />
      <Route path=":id" element={<UserDetails readOnly />} />
      <Route path=":id/edit" element={<UserDetails />} />
    </Route>
    <Route path="roles">
      <Route index element={<RoleList />} />
      <Route path="create" element={<RoleDetails />} />
      <Route path=":id" element={<RoleDetails readOnly />} />
      <Route path=":id/edit" element={<RoleDetails />} />
    </Route>
  </Route>
  <Route path="about" />
</Routes>

If the current route is /admin/users/123 then /admin and /admin/users links are active. It works fine. But I need to determine whether a link is active manually:

import { ExpandMore } from '@mui/icons-material';
import { ButtonBase, IconButton } from '@mui/material';
import cx from 'classnames';
import { NavLink, To, useMatch } from 'react-router-dom';

interface NavButtonProps {
  name: string;
  to: To;
}

export function NavButton(props: NavButtonProps) {
  const match = useMatch(props.to as string); // What pattern to use here?
  const active = Boolean(match);
  return (
    <ButtonBase component="div" className={cx({ active })}>
      <NavLink to={props.to}>
        {props.name}
      </NavLink>
      <IconButton onClick={openMenuWithLinksToNestedRoutes}>
        <ExpandMore />
      </IconButton>
    </ButtonBase>
  );
}

How to do it? What pattern should I use? For sure I can see a source code of NavLink component:

export const NavLink = React.forwardRef<HTMLAnchorElement, NavLinkProps>(
  function NavLinkWithRef(
    {
      "aria-current": ariaCurrentProp = "page",
      caseSensitive = false,
      className: classNameProp = "",
      end = false,
      style: styleProp,
      to,
      children,
      ...rest
    },
    ref
  ) {
    let path = useResolvedPath(to, { relative: rest.relative });
    let location = useLocation();
    let routerState = React.useContext(DataRouterStateContext);
    let { navigator } = React.useContext(NavigationContext);

    let toPathname = navigator.encodeLocation
      ? navigator.encodeLocation(path).pathname
      : path.pathname;
    let locationPathname = location.pathname;
    let nextLocationPathname =
      routerState && routerState.navigation && routerState.navigation.location
        ? routerState.navigation.location.pathname
        : null;

    if (!caseSensitive) {
      locationPathname = locationPathname.toLowerCase();
      nextLocationPathname = nextLocationPathname
        ? nextLocationPathname.toLowerCase()
        : null;
      toPathname = toPathname.toLowerCase();
    }

    let isActive =
      locationPathname === toPathname ||
      (!end &&
        locationPathname.startsWith(toPathname) &&
        locationPathname.charAt(toPathname.length) === "/");

But it's really complicated. I think there should be an easier way.


Solution

  • You could let the NavLink continue doing the work of knowing when it is active and "leak" out to the outer component scope when it is. NavLink has available to it a children render function prop that is passed an isActive prop. The following using a React ref may work for your needs.

    export function NavButton(props: NavButtonProps) {
      const isActiveRef = React.useRef(false);
    
      return (
        <ButtonBase
          component="div"
          className={cx({ active: isActiveRef.current })}
        >
          <NavLink to={props.to}>
            {({ isActive }) => {
              isActiveRef.current = isActive;
              return props.name;
            }}
          </NavLink>
          <IconButton onClick={openMenuWithLinksToNestedRoutes}>
            <ExpandMore />
          </IconButton>
        </ButtonBase>
      );
    }
    

    An alternative is to use a local state in NavButton and create a "child" component that can pass out the isActive status via a useEffect hook.

    const Label = ({ setActive, isActive, name }) => {
      useEffect(() => {
        setActive(isActive);
      }, [isActive, setActive]);
    
      return name;
    };
    
    export function NavButton(props: NavButtonProps) {
      const [active, setActive] = React.useState(false);
    
      return (
        <ButtonBase component="div" className={cx({ active })}>
          <NavLink to={props.to}>
            {({ isActive }) => (
              <Label
                name={props.name}
                isActive={isActive}
                setActive={setActive}
              />
            )}
          </NavLink>
          <IconButton onClick={openMenuWithLinksToNestedRoutes}>
            <ExpandMore />
          </IconButton>
        </ButtonBase>
      );
    }