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, { 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 };
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:
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!
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();
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.
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!
And as you can see, the entire tree is active, even if everything is collapsed:
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
Top comments (0)