Generative Data Intelligence

Recursion In React

Date:

Recursion is a powerful beast. Nothing satisfies me more than solving a problem with a recursive function that works seamlessly.

In this article I will present a simple use case to put your recursion skills to work when building out a nested Sidenav React Component.

Setting Up

I am using React version 17.0.2

First off, let’s get a boilerplate React App going. Make sure you have Nodejs installed on your machine, then type:

npx create-react-app sidenav-recursion

in your terminal, in your chosen directory.
Once done, open in your editor of choice:

cd sidenav-recursion
code .

Let’s install Styled Components, which I’ll use to inject css and make it look lovely. I also very much like the Carbon Components React icons library.

yard add styled-components @carbon/icons-react

and finally, yarn start to open in your browser.

Ok, let’s make this app our own!

First, I like to wipe out everything in App.css and replace with:

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

Then I add a file in src called styles.js and start with this code:

import styled from "styled-components";
const Body = styled.div`
  width: 100vw;
  height: 100vh;
  display: grid;
  grid-template-columns: 15% 85%;
  grid-template-rows: auto 1fr;
  grid-template-areas: "header header" "sidenav content";
`;
const Header = styled.div`
  background: darkcyan;
  color: white;
  grid-area: header;
  height: 60px;
  display: flex;
  align-items: center;
  padding: 0.5rem;
`;
const SideNav = styled.div`
  grid-area: sidenav;
  background: #eeeeee;
  width: 100%;
  height: 100%;
  padding: 1rem;
`;
const Content = styled.div`
  grid-area: content;
  width: 100%;
  height: 100%;
  padding: 1rem;
`;
export { Body, Content, Header, SideNav };

and then set up App.js like this:

import "./App.css";
import { Body, Header, Content, SideNav } from "./styles";
function App() {
  return (
    <Body>
      {" "}
      <Header>
        {" "}
        <h3>My Cool App</h3>
      </Header>
      <SideNav>This is where the sidenav goes</SideNav>
      <Content>Put content here</Content>
    </Body>
  );
}
export default App;

And you should have something like this:
image

Well done for getting this far! Now for the fun stuff.
First, we need a list of sidenav options, so lets write some in a new file, sidenavOptions.js:

const sidenavOptions = {
  posts: {
    title: "Posts",
    sub: {
      authors: {
        title: "Authors",
        sub: { message: { title: "Message" }, view: { title: "View" } },
      },
      create: { title: "Create" },
      view: { title: "View" },
    },
  },
  users: { title: "Users" },
};
export default sidenavOptions;

Each object will have a title and optional nested paths. You can nest as much as you like, but try not go more than 4 or 5, for the users’ sakes!

I then built my Menu Option style and added it to styles.js

const MenuOption = styled.div`
  width: 100%;
  height: 2rem;
  background: #ddd;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: ${({ level }) => `0 ${0.5 * (level + 1)}rem`};
  cursor: pointer;
  :hover {
    background: #bbb;
  }
  ${({ isTop }) =>
    isTop &&
    css`
      background: #ccc;
      :not(:first-child) {
        margin-top: 0.2rem;
      }
    `}
`;

and imported it accordingly. Those string literal functions I have there allow me to pass props through the React Component and use directly in my Styled Component. You will see how this works later on.

The Recursive Function

I then imported sidenavOptions to App.js and began to write the recursive function within the App.js component:

import { Fragment } from "react";
import "./App.css";
import sidenavOptions from "./sidenavOptions";
import { Body, Content, Header, SideNav, Top } from "./styles";
function App() {
  const [openOptions, setOpenOptions] = useState([]);
  const generateSideNav = (options, level = 0) => {
    return Object.values(options).map((option, index) => {
      const openId = `${level}.${index}`;
      const { sub } = option;
      const isOpen = openOptions.includes(openId);
      const caret = sub && (isOpen ? <CaretDown20 /> : <CaretRight20 />);
      return (
        <Fragment>
          {" "}
          <MenuOption
            isTop={level === 0}
            level={level}
            onClick={() =>
              setOpenOptions((prev) =>
                isOpen ? prev.filter((i) => i !== openId) : [...prev, openId]
              )
            }
          >
            {" "}
            {option.title} {caret}{" "}
          </MenuOption>
          {isOpen && sub && generateSideNav(sub, level + 1)}{" "}
        </Fragment>
      );
    });
  };
  return (
    <Body>
      {" "}
      <Header>
        {" "}
        <h3>My Cool App</h3>
      </Header>
      <SideNav>{generateSideNav(sidenavOptions)}</SideNav>
      <Content>Put content here</Content>
    </Body>
  );
}
export default App;

Let’s slowly digest what’s going on here.
first, I create a state that allows me to control which options I have clicked and are “open”. This is if I have drilled down into a menu option on a deeper level. I would like the higher levels to stay open as I drill down further.

Next, I am mapping through each value in the initial object and assigning a unique (by design) openId to the option.

I destructure the sub property of the option, if it exists, make a variable to track whether the given option is open or not, and finally a variable to display a caret if the option can be drilled down or not.

The component I return is wrapped in a Fragment because I want to return the menu option itself and any open submenus, if applicable, as sibling elements.

The isTop prop gives the component slightly different styling if it’s the highest level on the sidenav.
The level prop gives a padding to the element which increases slightly as the level rises. When the option is clicked, the menu option opens or closes, depending on its current state and if it has any submenus.

Finally, the recursive step! First I check that the given option has been clicked open, and it has submenus, and then I merely call the function again, now with the sub as the main option and the level 1 higher. Javascript does the rest!

image

You should have this, hopefully, by this point.

Let’s add routing!

I guess the sidenav component is relatively useless unless each option actually points to something, so let’s set that up. We will also use a recursive function to check that this specific option and its parent tree is the active link.
First, let’s add the React Router package we need:

yarn add react-router-dom

To access all the routing functionality, we need to update our index.js file to wrap everything in a BrowserRouter component:

import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter as Router } from "react-router-dom";
import App from "./App";
import "./index.css";
import reportWebVitals from "./reportWebVitals"; ReactDOM.render( <React.StrictMode> <Router> <App /> </Router>
 </React.StrictMode>,
 document.getElementById("root")
); 


reportWebVitals();

Now we need to update our sideNavOptions to include links. I also like to house all routes in my project in a single config, so I never hard-code a route. This is what my updated sidenavOptions.js looks like:

const routes = {
  createPost: "/posts/create",
  viewPosts: "/posts/view",
  messageAuthor: "/posts/authors/message",
  viewAuthor: "/posts/authors/view",
  users: "/users",
};
const sidenavOptions = {
  posts: {
    title: "Posts",
    sub: {
      authors: {
        title: "Authors",
        sub: {
          message: { title: "Message", link: routes.messageAuthor },
          view: { title: "View", link: routes.viewAuthor },
        },
      },
      create: { title: "Create", link: routes.createPost },
      view: { title: "View", link: routes.viewPosts },
    },
  },
  users: { title: "Users", link: routes.users },
};
export { sidenavOptions, routes };

Notice I don’t have a default export anymore. I will have to modify the import statement in App.js to fix the issue.

import {sidenavOptions, routes} from "./sidenavOptions";

In my styles.js, I added a definite color to my MenuOption component:

color: #333;

and updated my recursive function to wrap the MenuOption in a Link component, as well as adding basic Routing to the body. My full App.js:

import { CaretDown20, CaretRight20 } from "@carbon/icons-react";
import { Fragment, useState } from "react";
import { Link, Route, Switch } from "react-router-dom";
import "./App.css";
import { routes, sidenavOptions } from "./sidenavOptions";
import { Body, Content, Header, MenuOption, SideNav } from "./styles";
function App() {
  const [openOptions, setOpenOptions] = useState([]);
  const generateSideNav = (options, level = 0) => {
    return Object.values(options).map((option, index) => {
      const openId = `${level}.${index}`;
      const { sub, link } = option;
      const isOpen = openOptions.includes(openId);
      const caret = sub && (isOpen ? <CaretDown20 /> : <CaretRight20 />);
      const LinkComponent = link ? Link : Fragment;
      return (
        <Fragment>
          {" "}
          <LinkComponent to={link} style={{ textDecoration: "none" }}>
            {" "}
            <MenuOption
              isTop={level === 0}
              level={level}
              onClick={() =>
                setOpenOptions((prev) =>
                  isOpen ? prev.filter((i) => i !== openId) : [...prev, openId]
                )
              }
            >
              {" "}
              {option.title} {caret}{" "}
            </MenuOption>
          </LinkComponent>
          {isOpen && sub && generateSideNav(sub, level + 1)}{" "}
        </Fragment>
      );
    });
  };
  return (
    <Body>
      {" "}
      <Header>
        {" "}
        <h3>My Cool App</h3>
      </Header>
      <SideNav>{generateSideNav(sidenavOptions)}</SideNav>
      <Content>
        {" "}
        <Switch>
          {" "}
          <Route
            path={routes.messageAuthor}
            render={() => <div>Message Author!</div>}
          />{" "}
          <Route
            path={routes.viewAuthor}
            render={() => <div>View Author!</div>}
          />{" "}
          <Route
            path={routes.viewPosts}
            render={() => <div>View Posts!</div>}
          />{" "}
          <Route
            path={routes.createPost}
            render={() => <div>Create Post!</div>}
          />{" "}
          <Route path={routes.users} render={() => <div>View Users!</div>} />{" "}
          <Route render={() => <div>Home Page!</div>} />{" "}
        </Switch>
      </Content>
    </Body>
  );
}
export default App;

So now, the routing should be all set up and working.

image

The last piece of the puzzle is to determine if the link is active and add some styling. The trick here is not only to determine the Menu Option of the link itself, but to ensure the styling of the entire tree is affected so that if a user refreshes the page and all the menus are collapsed, the user will still know which tree holds the active, nested link.

Firstly, I will update my MenuOption component in styles.js to allow for an isActive prop:

const MenuOption = styled.div`
  color: #333;
  width: 100%;
  height: 2rem;
  background: #ddd;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: ${({ level }) => `0 ${0.5 * (level + 1)}rem`};
  cursor: pointer;
  :hover {
    background: #bbb;
  }
  ${({ isTop }) =>
    isTop &&
    css`
      background: #ccc;
      :not(:first-child) {
        margin-top: 0.2rem;
      }
    `} ${({ isActive }) =>
    isActive &&
    css`
      border-left: 5px solid #333;
    `}
`;

And my final App.js:

import { CaretDown20, CaretRight20 } from "@carbon/icons-react";
import { Fragment, useCallback, useState } from "react";
import { Link, Route, Switch, useLocation } from "react-router-dom";
import "./App.css";
import { routes, sidenavOptions } from "./sidenavOptions";
import { Body, Content, Header, MenuOption, SideNav } from "./styles";
function App() {
  const [openOptions, setOpenOptions] = useState([]);
  const { pathname } = useLocation();
  const determineActive = useCallback(
    (option) => {
      const { sub, link } = option;
      if (sub) {
        return Object.values(sub).some((o) => determineActive(o));
      }
      return link === pathname;
    },
    [pathname]
  );
  const generateSideNav = (options, level = 0) => {
    return Object.values(options).map((option, index) => {
      const openId = `${level}.${index}`;
      const { sub, link } = option;
      const isOpen = openOptions.includes(openId);
      const caret = sub && (isOpen ? <CaretDown20 /> : <CaretRight20 />);
      const LinkComponent = link ? Link : Fragment;
      return (
        <Fragment>
          {" "}
          <LinkComponent to={link} style={{ textDecoration: "none" }}>
            {" "}
            <MenuOption
              isActive={determineActive(option)}
              isTop={level === 0}
              level={level}
              onClick={() =>
                setOpenOptions((prev) =>
                  isOpen ? prev.filter((i) => i !== openId) : [...prev, openId]
                )
              }
            >
              {" "}
              {option.title} {caret}{" "}
            </MenuOption>
          </LinkComponent>
          {isOpen && sub && generateSideNav(sub, level + 1)}{" "}
        </Fragment>
      );
    });
  };
  return (
    <Body>
      {" "}
      <Header>
        {" "}
        <h3>My Cool App</h3>
      </Header>
      <SideNav>{generateSideNav(sidenavOptions)}</SideNav>
      <Content>
        {" "}
        <Switch>
          {" "}
          <Route
            path={routes.messageAuthor}
            render={() => <div>Message Author!</div>}
          />{" "}
          <Route
            path={routes.viewAuthor}
            render={() => <div>View Author!</div>}
          />{" "}
          <Route
            path={routes.viewPosts}
            render={() => <div>View Posts!</div>}
          />{" "}
          <Route
            path={routes.createPost}
            render={() => <div>Create Post!</div>}
          />{" "}
          <Route path={routes.users} render={() => <div>View Users!</div>} />{" "}
          <Route render={() => <div>Home Page!</div>} />{" "}
        </Switch>
      </Content>
    </Body>
  );
}
export default App;

I am getting the current pathname from the useLocation hook in React Router. I then declare a useCallback function that only updates when the pathname changes. This recursive function determineActive takes in an option and, if it has a link, checks to see if the link is indeed active, and if not it recursively checks any submenus to see if any children’s link is active.

Hopefully now the Sidenav component is working properly!

image

And as you can see, the entire tree is active, even if everything is collapsed:

image

There you have it! I hope this article was insightful and helps you find good use cases for recursion in React Components!

Signing off,

~ Sean Hurwitz

spot_img

Latest Intelligence

spot_img

Chat with us

Hi there! How can I help you?