Building a commentary sidebar in React (cont.)

This is the second of two parts. Part one is here.

In part one, we created a sidebar that lets us comment on articles. We built a React component that auto-aligns itself with specific words or phrases. The <Aside> component binds a listener in case the window is resized:

const Aside = ({ target, children, moveDown }) => {  const [style, setStyle] = useState(null);  const align = () => {    const rectangle = target.current.getBoundingClientRect();    const offset = window.scrollY + rectangle.top + moveDown;    setStyle({ top: offset });  };  useEffect(() => {    align(); // Align when the component is mounted.    const listener = window.addEventListener("resize", align);    return () => window.removeEventListener("resize", listener);  }, []);  return (    <aside className={css.aside} style={style}>      {children}    </aside>  );};export default Aside;

Continuing from where we left off

In this part, we’ll extend the component with additional functionality to make it more robust and better-able to cope with slow-loading content, print layouts, etc.

Slow-loading content

Currently the component positions itself when mounted, but what happens if images and fonts take a few seconds to load asynchronously? They might affect the layout by pushing content down, throwing our comments out of alignment. Something like this:

What we really want is for comments to re-align themselves after images and other slow-loading content has loaded. How can we get this to work?

Re-aligning comments after images have loaded

We could try and listen in to these events but that would mean polluting our app with event listeners for all manner of things: we’d have to register onLoad events for images and try to use the Font Loading API to detect when fonts have loaded.

The simplest approach I’ve found is to just use timeouts re-align comments at set times in anticipation of slow-loading content. This is a hack and isn’t perfectly reliable, but I’ve found it works well enough in practice and it’s easy to implement:

const Aside = ({ target, children, moveDown = 0 }) => {  // ...  useEffect(() => {    const delays = [200, 500, 1000, 2500, 5000, 15000, 30000];    const timeouts = delays.map(d => window.setTimeout(callback, d));    return () => timeouts.forEach(t => window.clearTimeout(t));  }, []);  // ...};

Using timeouts to re-align <Aside> elements

There are cases where this approach won’t work. For example, if the user can expand something on the page, the layout might change. We could additionally use setInterval and periodically re-align elements every few seconds as well.

Hooks

To avoid cluttering our component, we can extract functionality into React hooks:

const useDelays = (callback, delays, dependencies = []) => {  useEffect(() => {    const timeouts = delays.map(d => window.setTimeout(callback, d));    return () => timeouts.forEach(t => window.clearTimeout(t));  }, dependencies);};export default useDelays;

Extracting a hook to register the timeouts

While we’re at it, let’s do the same for our resize listener:

const useResize = (callback, dependencies = []) => {  useEffect(() => {    const listener = window.addEventListener("resize", callback);    return () => window.removeEventListener("resize", listener)  }, dependencies);};export default useResize;

Extracting a hook to listen to the resize event

This neatens up our component considerably:

const Aside = ({ target, children, moveDown = 0 }) => {  // ...  useEffect(align, []); // Align when the component is mounted.  useResize(align);  useDelays(align, [200, 500, 1000, 2500, 5000, 15000, 30000]);  // ...};

A much tidier component that uses hooks

We’ll use this approach of extracting hooks to keep our component clean as we add sophistication. It also means these behaviours are modular and can be reused later.

A race condition

Occasionally, when resizing the window, React would throw the following error:

getBoundingClientRect error
An error that sometimes happens on resize

I think this happens because of a race condition between the resize event and React’s event loop. Sometimes target refers to an undefined element.

This shouldn’t be the case because the referenced element always exists and is before the <Aside> in the DOM, but React might be in the middle of re-rendering the component and hasn’t updated its ref yet. It throws an error when we try to use it.

We can fix this problem with a guard condition in the align function:

const align = () => {  const current = target.current;  if (!current) return; // Guard a race condition.  const rectangle = current.getBoundingClientRect();  const offset = window.scrollY + rectangle.top + moveDown;  setStyle({ top: offset });};

Fixing the problem by adding a guard clause

Debouncing

The above error draws attention to the fact that the browser fires hundreds of events when the window is resized. This exasperates the problem because React competes with the resize handler for execution time, making the race condition more likely.

We’ve fixed the problem by guarding against it, but it’s probably sensible to also reduce the number of re-alignments per second. There’s no need to re-align hundreds of times in a row as the user drags the edges of their browser to resize it.

In the same spirit as before, we’ll use a hook for this:

const alignSoon = useDebounce(align, 50);useResize(alignSoon);useDelays(alignSoon, [200, 500, 1000, 2500, 5000, 15000, 30000]);

Wrapping the align function with a useDebounce hook

Now when the user resizes their browser, there will be a delay of 50 milliseconds before comments are re-aligned, vastly reducing the overhead of resizing.

This is noticeable, too. Before, when resizing the browser, the page was glitchy and slow to update. It’s much smoother with debouncing. I didn’t write the debouncing implementation, I used this one from Tom Stuart and Paul Mucur.

Printing

Honestly, printing web pages is a mess. You’d expect to get more-or-less the same version of the page you can see when you hit print, but that’s not how it works. Instead, it’s steeped in history and works in arbitrary and unexpected ways.

For example, some browsers fire onbeforeprint and onafterprint events. There’s also MediaQueryList but only some browsers support event listeners. Other browsers scale content down by default and remove backgrounds and images.

Why do I care?

I don’t actually think anyone will print these pages. The reason I care is because printing has subsumed a feature people do actually use: exporting web pages to PDF.

I’d like to be able to offer downloadable versions of articles for offline reading or perhaps even produce a short ebook someday. It’d be nice to use all the features I’ve developed for this blog without having to familiarise myself with a new tool.

The problem

When I first tried to print, I found the alignment of comments in the sidebar was slightly off. Everything was a bit lower than it should be and this was exaggerated further down with comments toward the end of the article. What’s going on?

Wrong print alignment
Incorrect alignment in Chrome’s print dialogue

I think what’s happening is a fresh version of the page is rendered when the print dialogue is opened. It changes the layout of the page by scaling its content and setting margins but doesn’t trigger a resize event to re-align the comments.

You can control some things through print stylesheets and @media print style rules but it’s harder to reliably detect in JavaScript when the page is being printed.

The solution

Eventually, I wrote another hook that works most of the time:

const usePrint = (onChange) => {  const [isPrinting, setPrinting] = useState(false);  const handleChange = (printing) => {    if (printing === isPrinting) return;    setPrinting(printing);    onChange(printing);  };  useEffect(() => {    const beforeListener = window.addEventListener("beforeprint", () => handleChange(true));    const afterListener = window.addEventListener("afterprint", () => handleChange(false));    const printMedia = window.matchMedia("print");    const printListener = printMedia.addListener((e) => handleChange(e.matches));    return () => {      window.removeEventListener("beforeprint", beforeListener);      window.removeEventListener("afterprint", afterListener);      printMedia.removeListener(printListener);    };  });  return isPrinting;};export default usePrint;

A React hook to detect print events

I then use this hook in the <Aside> component to trigger a re-alignment. It’s important not to debounce these calls. If we do, the print dialogue blocks the page while it renders a print version and we’ve missed our slot.

Here’s how use the hook:

const Aside = ({ target, children, moveDown = 0 }) => {  // ...  useEffect(align, []);  usePrint(align);  // Immediately call the function.  // ...};

Re-aligning <Aside> elements on print

This hook isn’t perfect but it seems to work well in Chrome and Safari. I still need to do more work to add a print stylesheet to make it look nice but at least it’s no longer broken... except in Firefox which likes to overlap all the comments:

Broken comments while printing in Firefox
Printing comments in Firefox is still broken

I’ll try to fix it someday but for now I’m happy it works in WebKit. That means I can produce prepared PDFs, rather than encourage anyone to use the print dialogue.

Final touches

It can look messy if <Aside> elements jump around a lot as the page loads. This will happen a bit as slow-loading content is added to the page but we can smooth things over a little by fading in our comments after the first alignment has happened:

const align() {  //...  setStyle({ top: offset, opacity: 1, transition: "opacity 0.3s" });}

Also, if you’re using <Aside> elements a lot, be mindful that each time you do, even listeners are being registered on window. This can add up over time:

Many resize listeners
Event listeners registered for the window

It would be better to register a single handler for each type of event and re-align all comments through that. I haven’t implemented this but will probably add this later.

Final thoughts

In this article, we’ve taken a detailed look at how to build a commentary sidebar in React. This has a few rough edges but overall I’m really happy with how it turned out.

It’s already become an integral part of how I think about writing. A tool I can reach for when I want to say a little bit more about something, without being too distracting.

I’m really enjoying the process of writing and learning React so it’s been fun to combine the two. For reference, here’s the most up to date version of the code:

import React, { useState, useEffect } from "react";import css from "./styles.scss";import usePrint from "../../hooks/use_print";import useDebounce from "../../hooks/use_debounce";import useResize from "../../hooks/use_resize";import useDelays from "../../hooks/use_delays";const Aside = ({ target, children, moveDown = 0 }) => {  const [style, setStyle] = useState(null);  const align = () => {    const current = target.current;    if (!current) return; // Guard a race condition.    const rectangle = current.getBoundingClientRect();    const offset = window.scrollY + rectangle.top + moveDown;    setStyle({ top: offset, opacity: 1, transition: "opacity 0.3s" });  };  useEffect(align, []);  usePrint(align);  const alignSoon = useDebounce(align, 50);  useResize(alignSoon);  useDelays(alignSoon, [200, 500, 1000, 2500, 5000, 15000, 30000]);  return (    <aside className={css.aside} style={style}>      {children}    </aside>  );};export default Aside;

The ‘live’ version of the code powering this blog