Skip to content

Command Palette's Two Engines: Fuzzy Scorer and useSyncExternalStore

12/23/2025Food & CookingKUIreact8 min read

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:

  1. subscribe — registers a listener; the component re-renders when the store changes
  2. getSnapshot — returns the current store value synchronously
  3. getServerSnapshot — 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 Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox