Notifio's desktop app has an activity log: a black monospace box that prints what the monitor is doing. It is the component people screenshot when something looks wrong, so it has to be cheap enough to append to every few seconds and it has to not lie about what happened.
It is 57 lines of JSX and one line of reducer, and the reducer line is the one worth reading.
The cap
const MAX_LOG_LINES = 200;
case "log":
return {
...state,
logLines: [...state.logLines.slice(-(MAX_LOG_LINES - 1)), action.line],
};
slice(-199) and then append one. The off-by-one is the feature: take the last 199, add the new one, and the result is exactly 200. Writing slice(-MAX_LOG_LINES) and appending gives you 201, which nobody notices and which quietly makes the constant a lie.
The constant only exists because the renderer is a long-lived window and the monitor logs on a cycle. Uncapped, the array grows for as long as the app is open, and the thing that breaks first is not memory, it is that every append copies an array that got a little bigger, forever.
Why the keys are "wrong"
{lines.map((line, i) => (
<span key={i} className={...}>{line}</span>
))}
Index keys. The thing every React tutorial tells you not to do, and for a good reason: when a list is reordered or has items removed from the middle, index keys make React reuse the wrong component for the wrong data, and any state inside those components goes with it.
Now notice what the cap does to this list. Once there are 200 lines, every append drops element 0 and shifts everything down. The line that was key={7} becomes key={6}. Every single key in the list now refers to a different string than it did one render ago. The keys are not just unstable, they are maximally unstable: on every append after the 200th, all 200 of them change meaning.
And that is fine, because of what these elements are.
They are <span> tags containing a string. No input, no focus, no animation mid-flight, no useState anywhere beneath them. The only thing React can get "wrong" by reusing the span at position 6 for the string that used to be at position 7 is the text content, and updating text content is precisely what it does next. There is nothing to carry over incorrectly, because there is nothing to carry over.
The alternative is keying by content, which is actively worse here. Log lines are not unique. "Checked 5 searches, nothing new" appears hundreds of times in a session, so key={line} produces duplicate keys and a console warning. Keying by a generated id means the reducer has to mint one per line and the state grows from string[] to an object array, which costs an allocation per log line to buy a property nobody reads.
So: index keys, 200 positional spans, each render updating the text of the ones whose text changed. The sliding window turns the usual argument on its head. Because everything shifts, there is no "correct" identity to preserve, and the positional interpretation is the only one that produces the minimum amount of DOM work.
The scroll, and the one bug it would have
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (boxRef.current) {
boxRef.current.scrollTop = boxRef.current.scrollHeight;
}
}, [lines]);
Pin to the bottom after every change. [lines] as the dependency, not [lines.length], which matters once the cap is active: at a steady 200 lines the length stops changing, and a length-keyed effect would stop scrolling at exactly the moment the log becomes interesting. The reducer returns a new array on every append, so the identity check fires every time.
This is also the component's one honest flaw, and it is worth naming rather than defending. There is no "am I scrolled up?" check, so if you scroll back to read something from a minute ago, the next log line yanks you to the bottom. The fix is a dozen lines comparing scrollTop + clientHeight against scrollHeight with a tolerance and only auto-scrolling when the user was already at the bottom. It is not in there. The reason is that the box is 280px tall at text-xs with leading-[1.7], so it shows about a dozen lines at a time, and the actual behaviour people want from it is "show me the last thing that happened", with a Clear button next to it for when it gets noisy:
<button onClick={onClear}>Clear</button>
which dispatches { type: "logs_clear" } and sets the array back to [].
One regex decides what is green
const HIGHLIGHT_RE = /new listing|sent|found/i;
<span className={
HIGHLIGHT_RE.test(line)
? "font-semibold text-[#4ade80]"
: "text-[#a3a3a3]"
}>
Three alternatives, case insensitive, matched against the rendered text of the line. This is string-sniffing the log, which is the sort of thing that normally deserves a structured event type with a severity field.
It is three words because of what the log is for. The log is not a telemetry stream, it is a narrative, and the only question a human asks it is "did anything happen?" Grey means the monitor is working. Green means it did the thing you installed it for: a listing turned up, a reply went out. Everything else, including the errors, is grey, which sounds wrong until you remember that errors already have their own surfaces: a status word on the monitor card, a notice channel for the sentence that explains it, and an email for the cases where the app has stopped being useful and you are not at the machine.
The cost is real and small. A log line that happens to contain the word "found" in some unrelated sense goes green, and a new kind of good news goes grey until somebody adds a fourth alternative. That is a one-word edit in a file where the alternative was an event taxonomy.
Its neighbour, which has no queue at all
The toast works on the same principle of refusing to build the general thing:
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 slot. A second toast while the first is up replaces it and restarts the 2800ms, rather than stacking or queueing. The clearTimeout is the whole correctness story: without it, the first toast's timer fires partway through the second toast's life and clears a message the user had 900ms to read.
And the component is mounted the entire time:
export default function Toast({ message }: Props) {
return (
<div className={`fixed bottom-8 right-8 ... transition-all duration-200 ${
message ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0 pointer-events-none"
}`}>
{message}
</div>
);
}
No message && <Toast />. The div always exists and animates between two states, because a CSS transition needs both ends to be on the page. Conditional rendering gives you the enter animation and never the exit one, since the element is gone before the transition can run. pointer-events-none in the hidden state is the part you only find out you needed when an invisible rectangle in the bottom right corner starts eating clicks.
Neither of these is a general solution to anything. There is no toast queue, no severity levels, no log event taxonomy, no scroll-position tracking. All four of those are real features that this app does not have, and the thing keeping them out is that it has one window, one log, and one message at a time.
You can see the UI it lives in through the interactive product demos on notifio.app, and the builds are at notifio.app/download if you would rather read the log yourself. What the monitor is logging about is on the per-site pages: notifio.app/alerts/pararius is a representative one.
Top comments (0)