DEV Community

Cover image for Recursion In React
seanhurwitz
seanhurwitz

Posted on

Recursion In React

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.

image

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;
}
Enter fullscreen mode Exit fullscreen mode

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

import styled, { css } 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 };
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
      }
    `}
`;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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")
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
    `}
`;
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)