Notifio's desktop window is small: three tabs, a monitor panel, a list of searches, a finds list, an activity log. The state management in it is deliberately boring. Three pieces of it are interesting anyway, because each one is a decision that looks like an oversight until you ask what the alternative would do.
The toast hook, in full
import { useState, useCallback, useRef } from "react";
export function useToast() {
const [toast, setToast] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showToast = useCallback((msg: string) => {
setToast(msg);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setToast(null), 2800);
}, []);
return { toast, showToast };
}
One string, one timer, no queue. A second message while the first is on screen replaces it and restarts the 2800 milliseconds. There is no hideToast, no id, no severity, no action button, no stacking.
Twenty-five call sites across six components use it, and reading them all in one go is the argument for the design:
"Search added"
"Invalid URL"
"Search removed"
"Search URL updated, monitoring restarts from a fresh baseline"
"Logged out, auto-reply switched off for this site"
"Could not log out, try again"
"Checking every search now" / "Already checking"
"Auto-reply is on" / "Auto-reply is off"
"Opened Stripe checkout in your browser"
"Find history cleared"
"Logged in, session saved"
"Skipped login for this search"
"Paste a link to any listing on this site first"
"Saved. {n} field(s) will be filled in for you, exactly as you typed them."
Every one of those is a receipt for something the user just clicked. None of them is an alert the app generated on its own schedule, and that is the property that makes a single slot correct: a human can only click one button at a time, so two receipts can only collide if the second click happened while the first toast was still fading, in which case the second one is the one they are waiting for.
A queue would do the opposite. It would show somebody a message about an action they took four seconds ago, after they have moved on, and the newest information would be the last to arrive. For receipts, replacing is right and queueing is wrong.
Anything the app says on its own initiative goes somewhere else entirely: the activity log, a desktop notification, or the status line on the search row. Those have their own rules, and one of them is that a status is one word, so the sentence it needs lives in its own channel.
The component is always mounted
export default function Toast({ message }: Props) {
return (
<div
className={`fixed bottom-8 right-8 z-[200] rounded-lg border border-[#404040] bg-[#262626] px-4 py-3 text-sm font-medium text-[#fafafa] shadow-xl transition-all duration-200 ${
message ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0 pointer-events-none"
}`}
>
{message}
</div>
);
}
{toast && <Toast .../>} would have been the obvious way to write the call site, and it would have cost the exit animation, because an unmounted element cannot transition. So the div is permanently in the tree and animates between two class sets, with pointer-events-none in the empty state so an invisible box in the corner of the window cannot eat a click. Which incidentally sits one decision away from a page where we unmount content and lose exactly that, on the website side.
The empty state renders an empty div rather than nothing, so a screen reader finds nothing to announce, and the messages above are all confirmations of an action the user initiated and can see the result of. If any of them were the only notice of something, this would need a live region.
The prop is drilled, not contexted
showToast is passed explicitly into every component that needs it:
<MonitorCard showToast={showToast} />
<SitesList showToast={showToast} onLoginNeeded={setLoginSite} />
<RecentFinds showToast={showToast} limit={4} />
<AutoReplyPanel showToast={showToast} onSiteLoggedIn={...} />
Four props where a context would be zero. The reason is that the prop is a declaration: a component's signature says out loud that it talks to the user. When a component stops needing it, the prop goes, and the next person can see which parts of the window can speak and which cannot. A context makes showToast ambient, and ambient user-facing output is how you end up with four components all announcing the same event.
One asymmetry worth pointing at, from the monitor panel:
async function handleStart() {
setBusy(true);
const error = await app.start();
setBusy(false);
if (error) showToast(error);
}
async function handleStop() {
setBusy(true);
await app.stop();
setBusy(false);
}
Starting can fail, and when it does the server's own sentence is the toast. Succeeding says nothing, because the entire panel visibly changes state. Stopping says nothing either way, because stopping cannot fail.
That is the rule the whole file follows: a toast exists for outcomes the UI cannot show by itself. "Search added" gets one because the new row appears at the bottom of a list that may be scrolled. "Monitoring started" does not, because the dot turns green.
One tab stays mounted and two do not
{/* The monitor panel stays mounted so its state and the activity log
survive switching away and back. */}
<div role="tabpanel" id="panel-monitor" hidden={tab !== "monitor"}>
<MonitorCard showToast={showToast} />
<SitesList showToast={showToast} onLoginNeeded={setLoginSite} />
<RecentFinds showToast={showToast} limit={4} />
<ActivityLog lines={app.logLines} onClear={app.clearLog} />
</div>
{tab === "finds" && (
<div role="tabpanel" id="panel-finds">
<RecentFinds showToast={showToast} />
</div>
)}
{tab === "autoreply" && (
<div role="tabpanel" id="panel-autoreply">
<AutoReplyPanel showToast={showToast} onSiteLoggedIn={...} />
</div>
)}
Three tab panels, two mechanisms. The monitor panel is always rendered and hidden with the hidden attribute. The other two are conditionally rendered and genuinely unmounted when you are not looking at them.
The split is not laziness. The monitor panel holds things that would be destroyed by unmounting: a scroll position in an activity log, expanded rows, in-progress edits. Unmounting it to save a few hundred DOM nodes would mean somebody switching to the Finds tab and back loses their place in a log they were reading. The other two panels hold nothing that cannot be rebuilt from the app state in one render.
Using the hidden attribute rather than a display: none class is deliberate too: hidden is what assistive technology reads to exclude the subtree, and it cannot be accidentally overridden by a utility class later.
The thing I would flag if this were a review: a mounted hidden panel still re-renders when the app state updates, so the monitor panel is doing work while you are on the Finds tab. Our timers are already gated on whether anything on screen is counting down, which I wrote about in a hook whose return value nobody uses, but the re-renders triggered by polling updates happen regardless. At this size it is free. At ten times this size it would be a measurement worth taking.
The unread badge is one localStorage key
/** Remembers when the finds list was last looked at, so the badge means something. */
const SEEN_KEY = "notifio.findsSeenAt";
const [findsSeenAt, setFindsSeenAt] = useState<number>(() => {
const stored = Number(window.localStorage.getItem(SEEN_KEY));
return Number.isFinite(stored) ? stored : 0;
});
// Opening the tab is what counts as having seen them.
useEffect(() => {
if (tab !== "finds") return;
const now = Date.now();
window.localStorage.setItem(SEEN_KEY, String(now));
setFindsSeenAt(now);
}, [tab, app.finds.length]);
const unseenFinds = app.finds.filter((f) => f.foundAt > findsSeenAt).length;
No seen flag per find, no write back to the app's own data files, no server involvement. One timestamp, and a filter comparing it against the timestamp each find already carries.
The reason this lives in localStorage rather than with the finds themselves is that "have you looked at this" is a fact about the window, not about the listing. The finds file is read by the monitor process; a seen boolean in it would be the renderer writing into a file something else owns, for a question that only the renderer asks. Our window also reloads from scratch every time you open it, so renderer state has to be either derivable or stored, and this is the cheapest possible storage.
The lazy useState initialiser handles the garbage case for free: a missing key gives Number(null), which is 0, and anything non-numeric gives NaN, which Number.isFinite rejects. Either way the badge starts at "everything is unseen", which is the harmless direction to be wrong in on a tab somebody has not opened yet.
The dependency array is the subtle part. The effect re-runs on app.finds.length as well as on tab, so a listing that arrives while you are sitting on the Finds tab is marked seen immediately rather than lighting up a badge for a list you are currently looking at. Drop that dependency and the badge starts counting finds that are on screen.
Finds are the point of the whole app, so if you want to see what one looks like in practice, the window shows them with the site name and how long ago they appeared, and the same listings arrive by email. The app monitors fifteen rental portals, there are builds for macOS and Windows on the download page, and the help page is where the toasts above get explained in full sentences.
Top comments (0)