Skip to main content

Converting a list of JS objects into a parent-child tree

My previous approach to displaying my notes on this site was to render them as a tree of infinitely nested topics and subtopics:

A screenshot of a web page showing a list of topic links where subtopics are nested under parent topics.

Parents and children

To achieve that nesting, my solution was to manually give each note that represented a subtopic (or subsubsubtopic) a parent value (via markdown frontmatter) and then move any child notes (i.e. notes with a parent value) into their parent’s children array:

/**
 * Given an array of collection items, returns the array with child items nested under their parents.
 */
function nestChildren(collection: Note[]): Note[] {
  // Step 1: Create a mapping of item IDs to item data 
  const slugToNodeMap = collection.reduce(
    (nodesBySlug, item): Record<string, Note> => {
      // Add an empty children array to the item's existing data
      nodesBySlug[item.id.toLowerCase()] = { ...item, data: { ...item.data, children: [] } }
      return nodesBySlug
    },
    {} as Record<string, Note>,
  )
 
  // Step 2: Build the nested item tree
  const tree = collection.reduce((roots, item): Note[] => {
    // Find the node matching the current collection item
    const node = slugToNodeMap[item.id.toLowerCase()]
 
    if (item.data.parent) {
      // If the note has a parent...
      const parentNode = slugToNodeMap[item.data.parent.toLowerCase()]
 
      if (parentNode) {
        // ...add the item's data to the parent's children array
        parentNode.data.children.push(node)
      } else {
        console.error(`Parent slug "${item.data.parent}" not found (this should never happen).`)
      }
    } else {
      // If the item has no parent, treat it as a new root-level note
      roots.push(node)
    }
 
    // Return the updated tree and keep iterating
    return roots
  }, [] as Note[])
 
  // Return the final, nested tree
  return tree
}
 
/**
 * Returns all notes with child notes nested under their parents.
 */
export const getNestedNotes = async (): Promise<Note[]> =>
  nestChildren(await getCollection('writing', note => isNote(note)))

Incremental left padding

My solution for visually indicating how those notes related to each other was to multiply each note’s left-padding by 1.4x its descendent level:

<ul class="list-notes">
  {
    notes.map(note => {
      const getLinkText = (item: Note): string => item.data.title || item.id
 
      const getChildren = (item: Note, level: number) =>
        item.data.children && (
          <ul>
            {item.data.children
              .sort((a: Note, b: Note) => a.data.title.localeCompare(b.data.title))
              .map((child: Note) => (
                <li
                  class="before:content-['└'] before:pe-[0.3rem] mt-[0.1rem]"
                  style={`padding-left: ${level === 0 ? 0 : 1.4}rem`}
                >
                  <a href={`/${child.slug}/`} class="link">
                    {getLinkText(child)}
                  </a>
 
                  {/* Recursively render grandchildren, etc */}
                  {getChildren(child, level + 1)}
                </li>
              ))}
          </ul>
        )
 
      return (
        <li class="mb-2 break-inside-avoid-column">
          <a href={`/${note.slug}/`} class="link">
            {getLinkText(note)}
          </a>
 
          {getChildren(note, 0)}
        </li>
      )
    })
  }
</ul>

Maintaining note hierarchies is a chore

That was a fun programming puzzle to solve, but unfortunately it made my note-taking more difficult by forcing me to decide where each new thought belonged in the hierarchy before I could jot it down.

To remove that friction, I’ve adopted to a flat (and eventually searchable and filterable) approach to note-taking instead. But I wanted to share this former nested-topics solution in case anyone else finds it useful.