When behavior depends on indirect event signals (timing, scroll events, key events, async streaming), implement it as an explicit state machine with clear invariants—and always separate what caused the change from the side effect you observe.
Practical checklist:
pinned/un-pinned, hold/tap, awaiting-restore/restored).Example pattern (adapted):
// Invariant: only user input unpins; programmatic scrolls are excluded.
const pinnedRef = useRef(true);
const ourScrollTarget = useRef<number | null>(null);
const writeScrollTop = (el: HTMLDivElement, top: number) => {
const clamped = Math.max(0, Math.min(top, el.scrollHeight - el.clientHeight));
ourScrollTarget.current = clamped !== el.scrollTop ? clamped : null;
el.scrollTo({ top: clamped, behavior: 'smooth' });
};
const onScroll = () => {
// Ignore scroll events that belong to our own programmatic target.
if (ourScrollTarget.current !== null) {
const arrived = Math.abs(el.scrollTop - ourScrollTarget.current) <= 1;
if (arrived || pinnedRef.current) {
if (arrived) ourScrollTarget.current = null;
return;
}
ourScrollTarget.current = null;
}
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
const nextPinned = distance <= PINNED_THRESHOLD_PX;
pinnedRef.current = nextPinned;
};
// Unpin only on direct user input, not on resulting async scroll events.
el.addEventListener('wheel', e => { if (e.deltaY < 0) pinnedRef.current = false; });
This approach prevents misclassification and drift by making the logic’s “reasoning model” explicit—improving correctness across complex, asynchronous UI behavior (the same rigor also applies to time-based key/hold detection).