Skip to main content

DelayedIndicator

View as Markdown

Delays showing a transient value long enough to avoid flicker, and once shown, keeps it visible for a minimum duration.

DelayedIndicator<T> wraps any value, not just a boolean loading flag. Feed it every new value through set(); values you mark as "transient" are held back briefly before appearing and, once shown, stay for a minimum duration - everything else passes through immediately.

import { DelayedIndicator } from 'fluid-primitives';

const indicator = new DelayedIndicator({
    isTransient: value => value === true,
    onChange: visible => {
        spinnerEl.hidden = !visible;
    },
});

async function loadResults(query: string) {
    indicator.set(true);
    try {
        const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
        const results = await response.json();
        renderResults(results);
    } finally {
        indicator.set(false);
    }
}

Call indicator.destroy() when it's no longer needed (e.g. on component teardown) to cancel any in-flight timer.

The options of a DelayedIndicator.

NameDescriptionRequiredDefault
showDelayMsnumber
How long a transient value must persist before it is shown at all. Must be finite and >= 0.
No
150
minVisibleMsnumber
Once shown, how long a transient value stays current before a settled value can replace it. Must be finite and >= 0.
No
300
isTransient(value: T) => boolean
Marks which values are transient, e.g. a loading state. Every other value is treated as settled and passes through immediately once no transient value is held.
Yes
-
onChange(value: T) => void
Called with each value once it is ready to be shown.
Yes
-

Because isTransient/onChange work on any value type, one DelayedIndicator can keep several pieces of UI in sync - for example a spinner and a status message that should always change together. From the Combobox Async Search example:

type SearchStatus = 'loading' | 'error' | 'empty' | 'idle' | 'results';

const status = new DelayedIndicator<SearchStatus>({
    isTransient: s => s === 'loading',
    onChange: renderStatus, // toggles both the spinner and the status text together
});

status.set(getSearchStatus(api, hasResults));

renderStatus reads the single incoming status value to update both the spinner's visibility and the status text, so the two are always derived from the same source and change in lockstep.

Delays showing a transient value long enough to avoid flicker, and keeps it visible for a minimum duration once shown. Feed it every new value through set(), typically straight from a subscribe callback, and it forwards them to onChange. The values isTransient marks are held back for showDelayMs, so a fast operation never flashes them. Once shown they stay for at least minVisibleMs, so they can't vanish the instant they appeared.

It works on any value, not only a boolean: one instance can drive a spinner and a status text from the same source, so they never disagree.

class DelayedIndicator<T>
NameDescriptionRequiredDefault
optionsDelayedIndicatorOptions<T>Yes
-

set()

Passes a new value on, delayed as described above. It does not deduplicate: the same value again can still call onChange again, as this filters state over time, it is not a distinctUntilChanged().

set(value: T): void
NameDescriptionRequiredDefault
valueT
The new value.
Yes
-

destroy()

Cancels any timer that is running. Call it when the indicator is no longer needed, e.g. on teardown: otherwise a scheduled value can still reach onChange afterwards. It is safe to call when nothing is transient or shown.

destroy(): void