Implementing Nested Filters using React and Tree Data Structure
Tags: react, tree, typescript, nestedfilters
Published: 2023-09-01 06:34:50

✨ [**Watch on YouTube**](https://youtu.be/Atk9g-KT144)  |  🐙 [GitHub](https://github.com/radzionc/reactkit/blob/main/app/pages/nested-filter.tsx)  |  🎮 [Demo](https://reactkit.radzion.com/nested-filter)

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

![Nested filters for habits page](https://cdn-images-1.medium.com/proxy/1*PQMKiA_inO5sl793d96v8g.jpeg)

## 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>`.

```ts
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.

```ts
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.

```ts
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.

```tsx
import { TreeNode } from "@reactkit/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.

```tsx
import { capitalizeFirstLetter } from "@reactkit/utils/capitalizeFirstLetter"
import { getTreeNode, getTreeValues } from "@reactkit/utils/tree"
import { withoutDuplicates } from "@reactkit/utils/array/withoutDuplicates"
import { HStack, VStack } from "@reactkit/ui/ui/Stack"
import { TreeFilter } from "@reactkit/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 "@reactkit/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.

```tsx
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.

```tsx
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 "@reactkit/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.

```tsx
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
}
```