Dev.to WebDev 🛠 Dev 👁 0 📖 9 min read

Four product demos on our home page, not one of them a video, and every loop has to be able to cancel itself

Notifio is a desktop app: it sits on your Mac or Windows machine, watches rental search pages, and raises a desktop notification and an email when a listing appears that was not there before. The website has to show that

Notifio is a desktop app: it sits on your Mac or Windows machine, watches rental search pages, and raises a desktop notification and an email when a listing appears that was not there before. The website has to show that to somebody who has not installed it.

The normal answer is a screen recording. There are four things to demonstrate, so that is four recordings, and I did not make any of them. The home page has four looping demos built out of DOM and framer-motion instead: an activity log ticking over, a browser page gaining a row, a macOS notification and a mail client, and an automated reply being typed into a form.

This post is about two things those four have in common. One is a cancellation pattern that every async animation loop needs and that is easy to get subtly wrong. The other is a rule about what a demo is allowed to claim.

Why not video

Not ideology. Four reasons that all turned out to be practical.

A recording of a desktop app goes stale the moment the app's UI changes, and it goes stale silently: the video keeps playing, it just shows last quarter's window. These demos are built from the same design tokens as the site, so a colour or a radius change reaches them.

A recording is also a single fixed size. These sit in a responsive layout, in consecutive rows that mirror each other: the browser demo with its text column on the left, then the notification demo with its text column on the right. Two rows that mirror each other have to agree about height, so the constant is duplicated on purpose:

// Matches NotificationDemo height exactly
const DEMO_HEIGHT = 320;

That line is in both components. I would rather it were in one place, but it is doing real work, and the comment is there so that whoever changes one number knows there is a second one.

Then there is weight. Four autoplaying videos on a landing page is a lot of bytes before anything is readable, and all four would need poster frames to not be a grey rectangle on a slow connection.

And last, the text in a demo is text. It can be selected, it gets found by in-page search, it inherits the page's font stack, and it renders at whatever pixel density the display has. None of that is true of a frame of H.264.

The pattern all four share

Each demo is a sequence of states with pauses between them, looping forever. That shape wants to be written as an async function, and useEffect has no idea what to do with one.

Here is the whole of the browser demo's loop:

export default function BrowserDemo() {
  const [showNew, setShowNew] = useState(false);
  const [showToast, setShowToast] = useState(false);
  const cancelled = useRef(false);

  useEffect(() => {
    cancelled.current = false;

    async function run() {
      setShowNew(false);
      setShowToast(false);

      await delay(1400);
      if (cancelled.current) return;

      setShowNew(true);
      setShowToast(true);

      await delay(3000);
      if (cancelled.current) return;
      setShowToast(false);

      await delay(3500);
      if (cancelled.current) return;
      setShowNew(false);

      await delay(600);
      if (!cancelled.current) run();
    }

    run();
    return () => { cancelled.current = true; };
  }, []);

Four things in there are deliberate and the first three are the ones I see people get wrong.

A check after every single await. Not one at the top of the loop. Once a function has four awaits in it, it has four points at which the component may have unmounted, and a setState after any of them is a call on a component that is gone. The useful way to read await delay(3000) is "the component may cease to exist here", and then the check after it is obviously necessary rather than defensive clutter.

A ref, not state. cancelled has to be readable by a closure that was created before the flag changed. State would not help: the async function captured cancelled at the time it started running, and a state value captured then is frozen at its value then. A ref is a stable box whose contents the closure reads at the moment it asks. This is the one case where "use a ref for mutable values the render does not depend on" is not a style preference but the only thing that works.

Cleanup sets a flag instead of clearing a timer. The obvious alternative is to keep handles to every pending setTimeout and clear them in cleanup. With four sequential awaits that means tracking which one is outstanding, which is bookkeeping proportional to the length of the sequence. Setting one flag is constant work regardless of how many steps there are, and the cost is that an already-scheduled timer still fires once and then returns immediately. One stray 600 millisecond timer per unmount is nothing. Bookkeeping that has to be updated every time somebody adds a step to the animation is not nothing.

The reset on the way in. cancelled.current = false at the top of the effect is the line that looks redundant and is not. The ref survives between effect runs, because it belongs to the component rather than to the effect. Cleanup sets it to true. If the effect then runs again, which React's development-mode double invocation guarantees and a remount causes in production, the loop starts with the flag already true and dies at its first check. The symptom is a demo that is frozen in development and fine in production, which is a bad afternoon.

The recursion at the tail. if (!cancelled.current) run() rather than a while (true) wrapper. It reads better and it means the loop condition sits in the same place as all the other cancellation checks.

The one demo with a user in it

The hero demo is the same pattern plus an interaction: it has a start and stop control, so the loop needs to know something that can change while it is suspended.

const [running, setRunning] = useState(false);
const cancelled = useRef(false);
const runningRef = useRef(false);

// Keep ref in sync so the loop can read latest value
useEffect(() => { runningRef.current = running; }, [running]);

That is the stale closure problem in its most quotable form. The loop cannot read running, because the value it closed over is from the render that started it. So running exists for rendering and runningRef exists for the loop, and one tiny effect keeps them equal. Two sources of the same truth is normally a smell; here the alternative is a loop that reads a value from the past.

The check then covers both reasons to stop:

for (const line of LOG_LINES) {
  await delay(pauseBefore(line));
  if (cancelled.current || !runningRef.current) return;
  // ...
}

Unmounted, or the user pressed stop. Same exit.

The pacing is the part of this demo I spent longest on:

/**
 * How long to wait before printing a line.
 *
 * A fixed tick made the whole cycle race past in a few seconds. Real polls are
 * 30s apart, so the long waits go before each new poll and the find is given a
 * beat to land, which reads as a monitor idling rather than a ticker.
 */
function pauseBefore(line: string): number {
  if (line.includes("Poll #1")) return 1300;
  if (line.includes("Poll #")) return 4200;
  if (line.includes("new listing found")) return 1700;
  return 1300;
}

A uniform delay per line produces something that looks like a stock ticker, and the app is not a ticker: it is a thing that waits, and waiting is the behaviour being advertised. Pausing longer before each new poll and giving the find a beat afterwards is the difference between "this scrolls text at you" and "this is sitting there watching". The timestamps in the log lines are 30 seconds apart even though the animation compresses them, because the interval is the product claim.

Dispatching on line.includes(...) is the shortcut I would fix first if this file grew. It couples pacing to log wording, so renaming a line silently changes its timing. For eleven fixed strings in one file it is fine, and I would rather admit that than pretend a lookup table keyed by a line id would have been worth it.

The rule about what a demo may claim

This is the part I would keep if I threw the code away.

The product's whole job is to show you real listings. A demo of it is therefore one keystroke away from advertising a specific flat at a specific price in a specific city, which does not exist. So the demos do not name anything they cannot stand behind.

Listing titles are grey bars:

function Pill({ width = "w-12" }: { width?: string }) {
  return <span className={`inline-block align-middle ${width} h-1.5 rounded-full bg-white/10 mx-0.5`} />;
}

used inline where a real title would be:

<p>Room 18m², <Pill width="w-16" /></p>

The reader understands immediately: there is a title there, it is not for them, this is a mock. A plausible-looking street name in that slot would read as a real listing, and a real listing that nobody can click is a small lie on a page whose entire argument is that we tell you true things quickly.

The browser demo's address bar goes further:

<span className="text-[10px] text-[#404040] truncate">rental-site.com/listings/search?city=all</span>

A deliberately fake domain. The demo could have shown a real rental site's URL and approximated its layout, and that would have been both more impressive and a worse idea: it borrows a brand to make a point about my software, in a mock the other company never agreed to. rental-site.com is unmistakably a stand-in. The specific sites the app actually supports are named on their own pages, like the Kamernet page, where there is room to say something true and sourced about each one.

The log lines do the same thing:

const LOG_LINES = [
  "[09:14:01] Monitor started",
  "[09:14:02] Poll #1: 2 site(s)",
  "[09:14:03] Baseline saved for site 1",
  // ...
  "[09:15:06] 1 new listing found on site 1",
];

"site 1" and "site 2", not real domains. The wording and format match what the app's own activity log prints, so it is a faithful mock of a real surface with the identifying details removed.

The only invented specifics left are prices: €895, €1,150, €780, €1,650 a month. Those are a range, not a claim about a property, and a listing row with no number in it is unreadable as a listing row. That is the line I drew, and I think it is in the right place, but it is a line rather than a principle.

Two things still wrong with this

There is no prefers-reduced-motion handling. Four looping animations above the fold is exactly the case that setting exists for, and respecting it would mean each demo rendering a single representative state and stopping. That is the next change to these files.

And the animation keys are prices:

{visible.map((l) => (
  <motion.div key={l.price} layout /* ... */ >
))}

Four hardcoded listings with four distinct prices, so it works. It works for the wrong reason, and a fifth listing that happened to share a price with an existing one would produce a duplicate key and a confused layout animation. An index would be worse under AnimatePresence, since rows enter and leave. A stable id on each literal is the right answer and it is a two-line change I have not made.

If you want to watch all four, they are on notifio.app, in order down the page: the activity log in the hero, then the browser row, then the notification row, then the auto-reply one below the features grid. The app they are demonstrating is at notifio.app/download. For the honest version of the notification behaviour, with no animation involved, Five rooms in one search is one banner, not five covers what the real one does.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.