Dynamically adding active class to multi level sidebar menu in REACT JS

Viewed 1493

I'm currently creating a multilevel sidebar menu in REACT and I want to dynamically add active class to parent <li> when the current route is active.

The code below is how I do it for a single menu.

<Route
  path={this.props.to}
  exact={this.props.activeOnlyWhenExact}
  children={({ match }) => (
    <li className={match ? "active" : ""}>
      <Link to={this.props.to} activeClassName="active">
        <span className="sidebar-mini-icon">
          <i className={this.props.icon}></i></span>
          {this.props.label}
      </Link>
    </li>
  )}
/>

Now I wonder how I do it with multilevel menu. My menu looks like this;

enter image description here

As you can see, only the menu has the active class. Here's my code for the multi level menu:

<Route
  path={this.props.to}
  exact={this.props.activeOnlyWhenExact}
  children={({ match }) => (
  <li>
    <a data-toggle="collapse" href="#Collapse">
      <span className="sidebar-mini-icon">
        <i className={this.props.icon}></i>
      </span>
      <span className="sidebar-normal">
        {this.props.name}
        <b className="caret"></b>
      </span>
    </a>
    <div class="collapse" id="Collapse">
       <ul className="nav">
         {this.props.children}
       </ul>
    </div>
  </li>
  )}
/>

I'm using React Router. Any help will be appreciated. Thank you.

6 Answers

The solution would depend on your route configuration. If you are using non-exact routes, the solution with NavLink would be the most applicable.

Example:

routes.jsx

<Route path="/parent/child-a">
  <ChildA />
</Route>
<Route path="/parent/child-b">
  <ChildB />
</Route>
<Route path="/parent">
  <Parent />
</Route>

Using the above structure, /parent/child-a would match two routes (/parent and /parent/child-a) and the activeClassName prop on the NavLink would be applied on both HTML element.

The "challenge" with the above structure is that you are going to render <Parent /> in all three cases.

This could be solved by using nested/child routes.

routes.jsx

<Route path="/parent">
  <NestedRoutes />
</Route>

nestedRoutes.jsx

<Route path="/parent/child-a" exact>
  <ChildA />
</Route>
<Route path="/parent/child-b" exact>
  <ChildB />
</Route>

With the updated structure above, /parent/child-a would match the top-level route (and as a result add the activeClassName to your HTML element) and also match the child route and add the activeClassName there too.

Note that if you need /parent to also work as a route, you need to update your nested routes as follows:

nestedRoutes.jsx

<Route path="/parent" exact>
  <Parent />
</Route>

From my experience, if you find yourself handling these cases manually through JavaScript or useLocation you might want to revisit your route configuration. react-router is an excellent library in its simplicity and will probably handle your case out of the box.

My first instinct would be to add className={match ? "active" : ""} to the parent li, but since you're asking about it, I'm assuming you already tried that.

You can dig around in the children's props to find out if any of them have the match prop (or any other prop that would indicate they're active). The following function can find a match prop from the children.

const childMatch = children => {
  if (!children) { return false }
  if (Array.isArray(children)) {
    return children.reduce(
      (acc, child) => acc || Boolean(child.props.match),
      false
    )
  }
  return Boolean(children.props.match)
}

You could then use it like className={childMatch(this.props.children) ? "active" : ""}.

If the prop you're looking for is deeper down the children tree, you can of course dig deeper. You can find grandchildren as child.props.children.

Option 1

You can use the useLocation hook.

With the useMemo hook, you can run a function whenever, the location changes. That way you add the class to the active parent on every location change.

But you'll need to make sure that the useLocation call is not in the same component that puts the router into the DOM.

So in the root index file, or some other component, you can have something like this

import { useLocation, useEffect } from "react-router-dom";

const Item = () => {
  const location = useLocation();

  // Select the nav item with the active class, and add the extra class to its parent
  useMemo(() => document.querySelector(activeClassName)?.parentElement?.classList.add(activeParentClassName), [location])
  
  return (...);
}

Option 2

This is based on the assumption, that the routes that are grouped have an identical starting route like /a/b/c and a/b/e

A multi-level menu item should be render like this.

The label of the group should have its own Route, it shouldn't be exact and should have a path that makes it a parent-directory to the actual menu links

<li>
  <Route
    path="/a"
    children={({ match }) => (
      <a data-toggle="collapse" href="#Collapse" className={match ? 'active' : ''}>
          ...
      </a>
    )}
  />

And then the actual link items in the menu, should be exact and have a path that makes it a sub-directory to the first key

<ul>
  <Route
    exact
    path="/a/b"
    children={({ match }) => (
      <li className={match ? "active" : ""}>
        <Link to={this.props.to} activeClassName="active">
          {/* ... */}
        </Link>
      </li>
    )}
  />
  {/* other routes */}
</ul>

This is a great question!

If I understand correctly, the desire is to dynamically add an active class to an li element if the current route matches.

To do that, lets create a new componet called NavListItem. NavListItem will check to see if the current route matches its to prop. If it does, it will render the activeClassName. This is not too unlike the react-router-doms NavLink component. Except that we will render an li instead of a link.

NavListItem.js

import React from 'react';
import { useLocation, matchPath } from 'react-router-dom';

export const NavListItem = ({
  path,
  exact,
  sensitive,
  strict,
  activeClassName = 'active',
  className,
  children,
  ...otherProps
}) => {
  const location = useLocation();

  const match = matchPath(location.pathName, {
    path: path,
    exact: exact,
    sensitive: sensitive,
    strict: strict,
  });

  const isActive = Boolean(match);

  const className = isActive ? [activeClassName, className].join(' ') : className;

  const props = {
    className: className,
    ...otherProps,
  };

  return <li {...props}>{children}</li>;
};

And here is the usuage with your example code.

<NavListItem path={this.props.to} exact={this.props.activeOnlyWhenExact}>
  <a data-toggle="collapse" href="#Collapse">
    <span className="sidebar-mini-icon">
      <i className={this.props.icon}></i>
    </span>
    <span className="sidebar-normal">
      {this.props.name}
      <b className="caret"></b>
    </span>
  </a>
  <div class="collapse" id="Collapse">
    <ul className="nav">{this.props.children}</ul>
  </div>
</NavListItem>

As per your question. I would suggest to use React router's nav link

<NavLink to="/<route-name>" activeClassName=<classname>>
  Title
</NavLink>
Related