Contents

React or Gatsby Table of Contents

April 15, 2020 | 7 min read

I needed a floating (sticky) table of contents in a sidebar for this Gatsby blog. I’ve found this article about building sticky responsive ToC (table of contents) and I’ve based my work on that (thank you, Janosh!).

The resulting table of contents. Also, you can see it in this blog.
The resulting table of contents. Also, you can see it in this blog.

How it works

We get page headings from markdown engine or by querying headings by document.querySelectorAll. We need to get for every heading the following data:

  • value, text
  • id to link to it
  • depth of nesting
  • vertical offset from top (relative to offsetParent node): htmlElement.offsetTop property

We listen for scroll events on a page and compare the current scroll position window.scrollY with every heading’s offsetTop. If the current scroll position is after a heading i offset and before the heading i+1 offset - we mark the heading i as active.

Implementation

The full implementation is here. I will explain the most important details of the implementation in the following sections.

How to use it

Table of contents with React

The implementation can query needed headings from DOM itself. In the minimal configuration we need only content container selector. The component will parse headings only in this container.

src/templates/blog-post.js
import Toc from "components/toc"

const BlogPostTemplate = ({ data, pageContext, location }) => {
  // ...
  const toc = <Toc containerSelector="#content section" />
  // ...
}

Table of contents with Gatsby

The ToC will work faster if we prebuild headings and don’t query DOM during page loading. If we use Gatsby with markdown we can query remark engine for headings: it will query headings during site compilation rather than during page loading.

But remark doesn’t build or return heading ids. First, we can build ids by using a plugin gatsby-remark-autolink-headers. And we can reconstruct heading ids by using the same algorithm it uses - just call github-slugger. It’s not the clean way but it’s safe: if our ids will differ from ids generated by the plugin we will get a build-time error. The error is generated inside the Toc component during server-side rendering if any heading wasn’t found in the DOM.

src/templates/blog-post.js
import Toc from "components/toc"
import slugs from "github-slugger"

const preprocessHeading = h => {
  const cleanValue = h.value.replace(/<(\/)?[^>]+>/g, "").replace(/\s{2,}/g, " ")
  return {
    depth: h.depth,
    value: cleanValue,
    id: slugs.slug(cleanValue),
  }
}

const BlogPostTemplate = ({ data, pageContext, location }) => {
  // ...
  const headings = data.markdownRemark.headings.filter(h => h.depth >= 2 && h.depth <= 4).map(preprocessHeading)  const toc = <Toc prebuiltHeadings={headings} containerSelector="#content section" />  // ...
}

export const pageQuery = graphql`
  query BlogPostBySlug($slug: String!) {
    // ...
    markdownRemark(fields: { slug: { eq: $slug } }) {
      // ...
      headings {        depth        value      }    }
  }
`

Also, we can configure needed heading levels, title, etc:

interface IProps {
  containerSelector: string // Selector for a content container
  levels?: number[] // Needed heading levels, by default [2, 3, 4]
  prebuiltHeadings?: IHeadingData[] // Already extracted page headings to speed up

  title?: string // Title, default is "Contents"
  throttleTimeMs?: number // Scroll handler throttle time, default is 300

  // Note: offsetToBecomeActive must not be zero because at least in my chrome browser
  // element.scrollTo() sets window.scrollY = element.offsetTop - 1
  // and some routers use this function to scroll to window.location.hash.
  // The default value is 30 (px).
  offsetToBecomeActive?: number
}

Performance

Let’s talk about implementation details leading to high performance.

Headings extraction

If we don’t provide headings in prebuiltHeadings the component they will be selected inside the component:

src/components/toc/component.tsx
    const container = document.querySelector(this.props.containerSelector) as HTMLElement
    const levels = this.props.levels || [2, 3, 4]
    const headingSelector = levels.map(level => `h${level}`).join(`, `)
    const htmlNodes: HTMLElement[] = Array.from(container.querySelectorAll(headingSelector))
    headings = htmlNodes.map((node, i) => ({
        value: node.innerText,
        depth: Number(node.nodeName[1]),
        id: node.id,
        htmlNode: node,
    }))

But this DOM querying and data fetching is expensive: in my Chrome browser for this page ToC headings are built in 100-200ms. If we provide prebuilt headings - the same page ToC headings are built in ~0.5ms.

Lazy DOM access

DOM access can be slow: e.g. offsetTop property can cause reflow. Therefore we do lazy access to such data. Heading nodes fetch an HTML element and it’s offsetTop only when needed by checking a cache version.

src/components/toc/heading-node.ts
export default class HeadingNode {
  // ...
  lazyLoad(curCacheVersion: number) {
    if (curCacheVersion === this.offsetCacheVersion) {
      return
    }

    if (!this.htmlNode) {
      this.htmlNode = document.getElementById(this.id)      if (!this.htmlNode) {
        throw Error(`no heading with id "${this.id}"`)
      }
    }

    this.cachedOffsetTop = this.htmlNode.offsetTop    this.offsetCacheVersion = curCacheVersion
  }
}

A cache version is incremented when DOM could have changed, e.g. lazy images were loaded or block was collapsed. We detect DOM changes by using mutation observer if it’s available. Otherwise we conservatively increment cache version on every scroll (it’s slower). The simplified version of the component code:

src/components/toc/component.tsx
export default class Toc extends React.Component<IProps, IState> {
  // ...
  private setupEventListeners() {
    let handleScroll: any
    if (typeof window === "undefined" || !window.MutationObserver) {
      handleScroll = () => {
        this.state.headingTree.markOffsetCacheStale()        this.handleScrollImpl()
      }
    } else {
      handleScroll = this.handleScrollImpl.bind(this)
      this.domObserver = new MutationObserver(mutations => {
        this.state.headingTree.markOffsetCacheStale()      })
      const container = document.querySelector(this.props.containerSelector)
      this.domObserver.observe(container, {
        attributes: true,
        childList: true,
        subtree: true,
        characterData: true,
      })
    }
    this.handleScrollThrottled = throttle(handleScroll, this.props.throttleTimeMs || 300)

    window.addEventListener(`scroll`, this.handleScrollThrottled)
    window.addEventListener(`resize`, () => {
        this.state.headingTree.markOffsetCacheStale()
    })
  }
}

The heading tree just increments a version in markOffsetCacheStale:

src/components/toc/heading-tree.ts
export class HeadingTree {
  // ...
  markOffsetCacheStale() {    this.offsetCacheVersion++  }}

Scroll handler

On every scroll event we look for the currently active heading:

src/components/toc/component.tsx
export default class Toc extends React.Component<IProps, IState> {
  // ...
  private findActiveNode(): HeadingNode | null {
    if (!this.state.headingTree) {
      return null
    }

    const offsetToBecomeActive = this.props.offsetToBecomeActive || 30
    const curScrollPos = window.scrollY + offsetToBecomeActive

    let activeNode = null
    let lastNode = null
    this.state.headingTree.traverseInPreorder((h: HeadingNode) => {      if (curScrollPos > h.cachedOffsetTop) {        lastNode = h        return TraverseResult.Continue      }      activeNode = lastNode      return TraverseResult.Stop    })
    if (activeNode === null && lastNode !== null && this.state.container) {
      // Mark last heading active only if we didn't scroll after the end of the container.
      if (window.scrollY <= this.state.container.offsetTop + this.state.container.offsetHeight) {
        return lastNode
      }
    }

    return activeNode
  }
}

But we throttle a scroll handler to run it no more than once per throttleTimeMs (300ms by default).

No unnecessary state changes

We find an active heading and all it’s parents inside scroll handler. But on a typical scroll active heading doesn’t change. We don’t change React state if an active section didn’t change and don’t trigger extra rendering:

src/components/toc/component.tsx
export default class Toc extends React.Component<IProps, IState> {
  // ...
  private handleScrollImpl() {
    const activeNode = this.findActiveNode()
    if (activeNode !== this.state.activeNode) {      const activeParents = this.buildActiveParents(activeNode)
      this.setState({ activeNode, activeParents })
    }
  }
}

Mobile version performance

Mobile version of ToC

On small screens we render our table of contents in a special way as you see in a GIF. ToC window can be closed by clicking on the closing icon or by clicking outside of the window. To close the window on outside click we need to listen for click events. To save CPU we add click event listener only when a window is opening and remove it on the window closing:

src/components/toc/component.tsx
export default class Toc extends React.Component<IProps, IState> {
  // ...
  private handleOpen() {
    if (!this.clickEventListenerWasAdded) {
      document.addEventListener("mousedown", this.handleClickOutside)      this.clickEventListenerWasAdded = true
    }
    this.setState({ open: true })
  }

  private handleClose() {
    if (this.clickEventListenerWasAdded) {
      document.removeEventListener("mousedown", this.handleClickOutside)      this.clickEventListenerWasAdded = false
    }
    this.setState({ open: false })
  }
}

Restrictions

Our table of contents component doesn’t support heading changes after a page creation: it can be useful during article writing with hot-reload. Also, one more mobile CPU optimization wasn’t performed: don’t set up a scroll handler until a table of contents window is open on small screens.


© 2020

Hi, my name is Denis Isaev and I'm a senior engineering manager at Yandex.

Contents