Command Palette's Two Engines: Fuzzy Scorer and useSyncExternalStore
Command Palette's Two Engines: Fuzzy Scorer and useSyncExternalStore
Command palettes solve two independent problems: registering commands and making them searchable. Each needs a different approach.
When ⌘K opens, two things run simultaneously: you get a list of all registered commands, and you score that list against the current query. kui-react's CommandPalette keeps these concerns separate — useCommandStore for the registry, useFuzzySearch for scoring.
Problem 1: The Global Command Registry
Commands come from different components. One page registers navigation commands, another adds form shortcuts, another registers a theme toggle. The CommandPalette needs to know about all of them.
The classic approach: Zustand or Redux. useCommandStore makes a more minimal choice — module-level store, bridged to React with useSyncExternalStore.
// modules/app/CommandPalette/hooks/useCommandStore.ts
type Listener = () => void;
const registry = new Map<string, CommandItem>();
const listeners = new Set<Listener>();
function notify() {
listeners.forEach((l) => l());
}
function subscribe(listener: Listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): CommandItem[] {
return Array.from(registry.values());
}
function getServerSnapshot(): CommandItem[] {
return [];
}
registry and listeners are module-level variables — the same instance across all imports. This is global state, but outside React: no Context, no Provider, no Zustand.
The useSyncExternalStore Bridge
export function useCommandRegistry(): CommandItem[] {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
useSyncExternalStore takes three arguments:
subscribe— registers a listener; the component re-renders when the store changesgetSnapshot— returns the current store value synchronouslygetServerSnapshot— SSR value (empty array)
When notify() is called, all components using useCommandRegistry() re-render. Cleanup: subscribe returns a cleanup function that removes the listener on unmount.
getServerSnapshot returns an empty array — no commands exist server-side, and there's no window. Hydration mismatches are avoided because CommandPalette checks a mounted state before rendering.
Registering Commands
export function registerCommand(cmd: CommandItem): () => void {
registry.set(cmd.id, cmd);
notify();
return () => {
registry.delete(cmd.id);
notify();
};
}
export function useRegisterCommand(cmd: CommandItem): void {
useEffect(() => {
return registerCommand(cmd);
}, [cmd.id]);
}
registerCommand is the imperative API — returns a teardown function. useRegisterCommand wraps it in a React lifecycle.
useEffect depends only on [cmd.id] — intentionally. If cmd itself were the dependency, a new object reference on every render would re-register on every render. The command's identity is stable (same ID), even if its label or action might change. registry.set with the same key updates the entry in place.
Problem 2: Fuzzy Scoring
The user types "sev" and expects "Settings > Device > Volume" to appear. A simple includes() check won't get there.
// modules/app/CommandPalette/hooks/useFuzzySearch.tsx
function scoreString(query: string, target: string): number {
if (!query) return 0;
const q = query.toLowerCase();
const t = target.toLowerCase();
if (t === q) return 200;
if (t.startsWith(q)) return 150;
let score = 0;
let qi = 0;
let lastMatch = -1;
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) {
if (ti === 0) score += 100; // string start
else if (/[ \-\/\_]/.test(t[ti - 1])) score += 30; // word boundary
else if (lastMatch === ti - 1) score += 10; // consecutive
lastMatch = ti;
qi++;
} else {
score -= 1; // gap penalty
}
}
if (qi < q.length) return 0; // not all query chars matched
return score;
}
Four scoring rules:
- Prefix (+100): First query character matches first target character
- Word boundary (+30): Previous character is space, dash, slash, or underscore — matches start of words
- Consecutive (+10): Previous match was the immediately preceding character
- Gap penalty (-1): Each skipped character subtracts 1
qi < q.length check: all query characters must be found in order. "xyz" matches "axyz" (sequential) but not "ayxz" (z comes before y).
Command-Level Scoring
function scoreCommand(query: string, cmd: CommandItem): number {
const labelScore = scoreString(query, cmd.label);
if (labelScore > 0) return labelScore;
const keywordScore = cmd.keywords
?.map((k) => scoreString(query, k))
.reduce((a, b) => Math.max(a, b), 0) ?? 0;
if (keywordScore > 0) return keywordScore * 0.5;
const catScore = scoreString(query, cmd.category ?? '');
return catScore > 0 ? catScore * 0.25 : 0;
}
Label first — users typically search for what they see. Keywords as fallback — "cmd+k" finds a "keyboard shortcut" command if "cmdk" is in keywords. Category as last resort with 0.25x weight — "Settings" is too broad to be a strong signal.
The useFuzzySearch Hook
export function useFuzzySearch(
commands: CommandItem[],
query: string,
): CommandItem[] {
return useMemo(() => {
if (!query.trim()) return commands;
return commands
.map((cmd) => ({ cmd, score: scoreCommand(query, cmd) }))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ cmd }) => cmd);
}, [commands, query]);
}
useMemo with [commands, query] — no recalculation when neither changes. Each keystroke triggers an O(N) scoring pass plus sort. With 1000 commands, each scoring operation is O(label_length × query_length) — well under a millisecond per command in practice.
highlightMatches() — Mark Rendering
export function highlightMatches(
text: string,
matchedIndices: number[],
): React.ReactNode {
const indexSet = new Set(matchedIndices);
const chars: React.ReactNode[] = [];
let i = 0;
while (i < text.length) {
if (indexSet.has(i)) {
let j = i;
while (j < text.length && indexSet.has(j)) j++;
chars.push(<mark key={i}>{text.slice(i, j)}</mark>);
i = j;
} else {
chars.push(text[i]);
i++;
}
}
return chars;
}
No dangerouslySetInnerHTML — pure React nodes. Consecutive matched characters collapse into a single <mark> element. CSS: mark { background: transparent; font-weight: 600; } — bold matched characters without a yellow highlight.
Putting the Two Together
function CommandPalette({ open }: { open: boolean }) {
const [query, setQuery] = useState('');
const commands = useCommandRegistry(); // subscribe to registry
const results = useFuzzySearch(commands, query); // score and sort
return open ? (
<dialog>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{results.map((cmd) => (
<li key={cmd.id} onClick={() => cmd.action()}>
{cmd.label}
</li>
))}
</ul>
</dialog>
) : null;
}
useCommandRegistry() re-renders only when commands are added or removed. useFuzzySearch() recomputes only when query or commands changes. Each keystroke triggers only the fuzzy scoring — the registry subscription stays quiet.
Trade-off
Module-level state without React Context is problematic during SSR. In Next.js App Router, module-level variables can be shared across requests if not properly isolated. getServerSnapshot: () => [] addresses the hydration issue, but the module singleton pattern has multi-tenant risks in SSR environments. CommandPalette is a client-only component ('use client') — this is the constraint that makes it safe.
The fuzzy scorer is blind to special characters. Searching "⌘K" won't find a command labeled "⌘K" if the character encoding differs. The keywords array bridges this — keywords: ['cmd+k', 'command+k', 'cmdk'] on the keyboard shortcut command.
Business Impact
Command palettes drive power user retention. A keyboard-first user who can reach any feature without lifting their hands off the keyboard stays engaged longer and completes tasks faster.
Fuzzy search quality determines adoption. If users type a partial word and find what they need, they use the palette again. If they have to type exact words, they switch to mouse. The word-boundary bonus is the difference between those two outcomes.
Something to Try
Add useSyncExternalStore with a module-level Map to your project:
const registry = new Map<string, () => void>();
const listeners = new Set<() => void>();
const subscribe = (l: () => void) => { listeners.add(l); return () => listeners.delete(l); };
const getSnapshot = () => Array.from(registry.entries());
export const useCommands = () => useSyncExternalStore(subscribe, getSnapshot, () => []);
export const registerCmd = (id: string, fn: () => void) => {
registry.set(id, fn);
listeners.forEach((l) => l());
return () => { registry.delete(id); listeners.forEach((l) => l()); };
};
Ten lines. No Context, no Provider, no extra useEffect. A global command subscription that any component can publish to or subscribe from.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox