function App() {
useBadgeController();
// …
}
export function useBadgeController(): void {
useEffect(() => {
// Badge state is external; keep it in the closure, not React state.
let state = initialBadgeState;
// Skip re-emitting an identical output (e.g. repeated mid-search 0 → animate).
let lastOutput: BadgeOutput | null = null;
// Serialize applies so their async getBadgeText reads don't interleave.
let applyChain: Promise<void> = Promise.resolve();
const handle = (event: BadgeEvent) => {
const result = reduceBadge(state, event);
state = result.state;
if (isSameBadgeOutput(lastOutput, result.output)) return;
lastOutput = result.output;
const output = result.output;
applyChain = applyChain.then(() => applyBadgeOutput(output));
};
const unsubscribers = [
onSearchEvent(SearchEvent.STARTED, () => handle({ type: SearchEvent.STARTED })),
onSearchEvent(SearchEvent.RESULTS_COUNT, ({ count }) =>
handle({ type: SearchEvent.RESULTS_COUNT, count }),
),
onSearchEvent(SearchEvent.COMPLETED, ({ count }) =>
handle({ type: SearchEvent.COMPLETED, count }),
),
onSearchEvent(SearchEvent.ABORTED, () => handle({ type: SearchEvent.ABORTED })),
onSearchEvent(SearchEvent.FAILED, () => handle({ type: SearchEvent.FAILED })),
];
const onResultsCleared = () => handle({ type: IDB_SEARCH_RESULTS_CLEARED });
window.addEventListener(IDB_SEARCH_RESULTS_CLEARED, onResultsCleared);
return () => {
for (const unsubscribe of unsubscribers) unsubscribe();
window.removeEventListener(IDB_SEARCH_RESULTS_CLEARED, onResultsCleared);
};
}, []);
}
Mount-once hook that subscribes to all search-lifecycle events plus
IDB_SEARCH_RESULTS_CLEARED, runs them through reduceBadge, and applies the result to the badge. Call this exactly once, near the top ofApp, before any code that emits search events.