Implementing Nested Filters using React and Tree Data Structure

August 30, 2023

8 min read

Implementing Nested Filters using React and Tree Data Structure
Watch on YouTube

Recently, I implemented nested filters for the habits page on increaser.org. This article will guide you through creating similar interfaces using React and the Tree data structure.

Nested filters for habits page
Nested filters for habits page

TreeNode in TypeScript

Initially, we need to define a type for our tree node. It will be a generic type, usable for any kind of data and it only contains two fields: value and children. Value is a generic type T and children is an array of TreeNode<T>.

export interface TreeNode<T> {
  value: T
  children: TreeNode<T>[]
}

To locate a specific node, we will traverse the tree to the desired node using the getTreeNode function. The path is an array of numbers, with each number being the index of the child node.

export function getTreeNode<T>(tree: TreeNode<T>, path: number[]): TreeNode<T> {
  return path.reduce((node, i) => node.children[i], tree)
}

To retrieve all the values of a given node, we will employ the getTreeValues function. This function returns an array of values of the specific node and all its children.

export function getTreeValues<T>(tree: TreeNode<T>): T[] {
  return [tree.value, ...tree.children.flatMap(getTreeValues)]
}

We can now define our tree of habits. The data of a node will be represented by the HabitTreeNodeValue which contains an id for the habit category, such as "health", "relationships", "work", etc. This also has an optional array of habit ids and an optional color. For instance, the "happiness" category doesn't have its own habits; instead, it's an combination of other habits defined in the children field.

import { TreeNode } from "@radzionkit/utils/tree"
import { HabitId } from "./habits"

export interface HabitTreeNodeValue {
  id: string
  habits?: HabitId[]
  color?: number
}

export interface HabitTreeNode extends TreeNode<HabitTreeNodeValue> {}

export const habitTree: HabitTreeNode = {
  value: {
    id: "happiness",
    color: 5,
  },
  children: [
    {
      value: {
        id: "health",
        color: 4,
      },
      children: [
        {
          value: {
            id: "sleep",
            habits: [
              "sunlight",
              "limitCoffee",
              "noAlcohol",
              "earlySleep",
              "noLateFood",
              "noWorkAfterDinner",
              "noElectronicsInBedroom",
            ],
          },
          children: [],
        },
        {
          value: {
            id: "nutrition",
            habits: ["morningFast", "noLateFood", "supplements", "content"],
          },
          children: [],
        },
        {
          value: {
            id: "body",
            habits: ["outdoors", "exercise", "walk"],
          },
          children: [],
        },
        {
          value: {
            id: "mind",
            habits: [
              "meditation",
              "learn",
              "max",
              "noWorkAfterDinner",
              "noElectronicsInBedroom",
            ],
          },
          children: [],
        },
      ],
    },
    {
      value: {
        id: "relationships",
        color: 11,
      },
      children: [
        {
          value: {
            id: "marriage",
            habits: [
              "compliment",
              "review",
              "help",
              "noWorkAfterDinner",
              "noElectronicsInBedroom",
            ],
          },
          children: [],
        },
      ],
    },
    {
      value: {
        id: "work",
        color: 2,
      },
      children: [
        {
          value: {
            id: "productivity",
            habits: [
              "noWorkAfterDinner",
              "sunlight",
              "limitCoffee",
              "noAlcohol",
              "earlySleep",
              "morningFast",
              "prepare",
              "noEarlyCoffee",
              "noLateFood",
              "outdoors",
              "exercise",
              "noElectronicsInBedroom",
            ],
          },
          children: [],
        },
      ],
    },
  ],
}

Following this, we find a list of habits that aren't aware they are being used in a tree. They are only a list of unique habit ids and information about them which includes emoji, name, and description.

Nested Filters with React

With React, we can now implement our nested filters. The present category is stored as an array of numbers in the useState hook. The 'happiness' category will be an empty array, the 'health' category will be [0], the 'sleep' category will be [0, 0], and so forth.

import { capitalizeFirstLetter } from "@radzionkit/utils/capitalizeFirstLetter"
import { getTreeNode, getTreeValues } from "@radzionkit/utils/tree"
import { withoutDuplicates } from "@radzionkit/utils/array/withoutDuplicates"
import { HStack, VStack } from "@radzionkit/ui/ui/Stack"
import { TreeFilter } from "@radzionkit/ui/ui/tree/TreeFilter"
import { useState, useMemo } from "react"
import styled from "styled-components"
import { HabitTreeNode, habitTree } from "./data/habitTree"
import { habitRecord } from "./data/habits"
import { Text } from "@radzionkit/ui/ui/Text"
import { HabitItem } from "./HabitItem"

const Container = styled(HStack)`
  width: 100%;
  flex-wrap: wrap;
  gap: 40px;
  align-items: start;
`

const Content = styled(VStack)`
  gap: 20px;
  flex: 1;
`

const FilterWrapper = styled.div`
  position: sticky;
  top: 0;
`

const getCategoriesColors = (
  { value, children }: HabitTreeNode,
  parentColor?: number
): Record<string, number | undefined> => {
  const color = value.color ?? parentColor

  return {
    [value.id]: color,
    ...children.reduce(
      (acc, child) => ({
        ...acc,
        ...getCategoriesColors(child, color),
      }),
      {}
    ),
  }
}

const defaultColor = 3

export const CuratedHabits = () => {
  const [path, setPath] = useState<number[]>([])

  const values = useMemo(() => getTreeValues(habitTree), [])
  const categoryColorRecord = useMemo(() => getCategoriesColors(habitTree), [])

  const node = getTreeNode(habitTree, path)

  const habits = withoutDuplicates(
    getTreeValues(node).flatMap((value) => value.habits || [])
  )
    .map((id) => ({
      id,
      ...habitRecord[id],
    }))
    .map((habit) => ({
      ...habit,
      tags: values
        .filter((value) => value.habits?.includes(habit.id))
        .map((value) => ({
          name: value.id,
          color: categoryColorRecord[value.id] ?? defaultColor,
        })),
    }))

  return (
    <Container>
      <FilterWrapper>
        <TreeFilter
          tree={habitTree}
          renderName={(value) => capitalizeFirstLetter(value.id)}
          value={path}
          onChange={setPath}
        />
      </FilterWrapper>
      <Content>
        <Text weight="bold" size={24}>
          {capitalizeFirstLetter(node.value.id)} habits{" "}
          <Text as="span" color="supporting">
            ({habits.length})
          </Text>
        </Text>
        {habits.map((habit) => (
          <HabitItem {...habit} key={habit.id} />
        ))}
      </Content>
    </Container>
  )
}

Using the getTreeValues function, we'll obtain all the habits in the tree. Every habit has a colored tag, but not all have a color field defined. It's only present at the category level. So, we'll use the getCategoriesColors to get a record of category ids and their colors. It's a recursive function that assigns the color of the parent category to its children if they don't have their own color defined.

To get the current node, we use the getTreeNode function. Some habits may be present in more than one category. For instance, the habit "View sunlight after waking up" is in both the "sleep" and "productivity" category. We don't want to display it twice, so we remove duplicates with the withoutDuplicates function. Then we add a list of tags to each unique habit. The tags represent the categories to which the habit belongs. We use the categoryColorRecord to fetch the color of the category.

export function withoutDuplicates<T>(
  items: T[],
  areEqual: (a: T, b: T) => boolean = (a, b) => a === b
): T[] {
  const result: T[] = []

  items.forEach((item) => {
    if (!result.find((i) => areEqual(i, item))) {
      result.push(item)
    }
  })

  return result
}

To display the habits, we pull from the habits array and exploit the HabitItem component. The generic TreeFilter component is what we'll depend on to filter the habits.

import { useState, Fragment } from "react"
import styled, { useTheme } from "styled-components"
import { Circle } from "../Circle"
import { NonEmptyOnly } from "../NonEmptyOnly"
import { VStack, HStack } from "../Stack"
import { defaultTransitionCSS } from "../animations/transitions"
import { getVerticalPaddingCSS } from "../utils/getVerticalPaddingCSS"
import { Text } from "../Text"
import { handleWithStopPropagation } from "../../shared/events"
import { InputProps } from "../../props"
import { TreeNode } from "@radzionkit/utils/tree"

interface TreeFilterProps<T> extends InputProps<number[]> {
  tree: TreeNode<T>
  renderName: (value: T) => string
}

const Content = styled(VStack)`
  margin-left: 20px;
`

const Container = styled(VStack)`
  cursor: pointer;
`

const Item = styled(HStack)`
  ${getVerticalPaddingCSS(4)}
  align-items: center;
  gap: 8px;
  ${defaultTransitionCSS}
`

export function TreeFilter<T>({
  tree,
  renderName,
  value,
  onChange,
}: TreeFilterProps<T>) {
  const [hovered, setHovered] = useState<number[] | undefined>()

  const { colors } = useTheme()

  const recursiveRender = (node: TreeNode<T>, path: number[]) => {
    const isSelected = value.every((v, i) => v === path[i])

    let color = isSelected ? colors.text : colors.textShy
    if (hovered) {
      const isHovered = hovered.every((v, i) => v === path[i])
      color = isHovered ? colors.text : colors.textShy
    }

    return (
      <Container
        onClick={handleWithStopPropagation(() => onChange(path))}
        onMouseEnter={() => setHovered(path)}
        onMouseLeave={() => {
          setHovered(
            path.length === 0 ? undefined : path.slice(0, path.length - 1)
          )
        }}
      >
        <Item
          style={{
            color: color.toCssValue(),
          }}
        >
          <Circle
            size={8}
            background={isSelected ? colors.primary : colors.transparent}
          />
          <Text weight="bold">{renderName(node.value)}</Text>
        </Item>
        <NonEmptyOnly
          array={node.children}
          render={(items) => (
            <Content>
              {items.map((child, index) => (
                <Fragment key={index}>
                  {recursiveRender(child, [...path, index])}
                </Fragment>
              ))}
            </Content>
          )}
        />
      </Container>
    )
  }

  return <>{recursiveRender(tree, [])}</>
}

Its props extend the generic InputProps, which consist of value and onChange props. In this case, the value will be the path to the node. We must also pass the entire tree to the component and a function that will render the name of the node.

As we're rendering a tree structure, we can't avoid recursion. In the recursiveRender function, we check if the current node is selected by comparing the value prop with the path argument. We then apply different styles to the filter item based on their selection status. We also update the color of the item when it's hovered by changing the hovered state. The handleWithStopPropagation function is used to prevent the click event from bubbling up to the parent element.

We render the children of the node within the Content component, which indents them from the parents with a 20px margin on the left side. As we don't want to render the Content component when there are no children, we use a small helper component called NonEmptyOnly to render the children only if they exist.

import { ReactNode } from "react"

interface NonEmptyOnlyProps<T> {
  array?: T[]
  render: (array: T[]) => ReactNode
}

export function NonEmptyOnly<T>({ array, render }: NonEmptyOnlyProps<T>) {
  if (array && array.length > 0) {
    return <>{render(array)}</>
  }

  return null
}