Skip to content

Architecture Overview ​

modern-cmdk is built as a three-layer architecture: a framework-agnostic core engine, a React 19 adapter, and optional performance plugins. Each layer has a single responsibility and communicates through well-defined interfaces.

Three-Layer Architecture ​

Layer 1: Core Engine (modern-cmdk) ​

Pure TypeScript state machine with no hard DOM dependency (DOM APIs are feature-detected and SSR-safe). Manages:

  • Item registry -- Registration, deregistration, grouping
  • Search and filtering -- Pluggable SearchEngine interface
  • Frecency ranking -- Time-based decay buckets with configurable weights
  • Keyboard shortcuts -- Shortcut string parser, conflict detection via groupBy helper
  • Navigation -- Active item tracking, page stack, loop/no-loop
  • State snapshots -- Immutable CommandState objects for efficient change detection

The core has no side effects beyond subscriber notification. It can be used with any UI framework or no framework at all.

Layer 2: React Adapter (modern-cmdk/react) ​

Compound component API that bridges the core state machine to React 19:

  • useSyncExternalStore -- Subscribes to machine state without tearing
  • useTransition -- Wraps only the optimistic active-item update; Command.Input keeps an urgent draft value and the machine catches up on its scheduler tick, so isPending never reflects a search
  • useOptimistic -- Optimistic active item updates for instant visual feedback
  • ref as prop -- React 19 native ref forwarding (no forwardRef)
  • "use client" directive -- The shipped React components are client-only: dist/react/index.mjs and dist/react/dialog.mjs begin with the directive, added as a banner by packages/modern-cmdk/tsdown.config.ts (the source files under src/react do not carry it)
  • Radix UI integration -- Dialog with focus trap, portal, overlay

Layer 3: Optional Plugins ​

  • WASM Search -- Rust trigram index compiled to WebAssembly for fuzzy search over large item sets
  • Web Worker -- Runs WASM search off the main thread with SharedArrayBuffer zero-copy transfer
  • Custom storage -- Pluggable FrecencyStorage implementations (memory, IndexedDB, custom)

State Machine Flow ​

Event Processing ​

  1. External code calls machine.send(event)
  2. The machine processes the event synchronously
  3. A new immutable CommandState snapshot is created
  4. All subscribers are notified with the new state
  5. React useSyncExternalStore triggers a re-render with the new snapshot

Data Flow: Items to Rendered Output ​

Search Pipeline ​

  1. Registration -- Items are registered via REGISTER_ITEM events (or React component mount)
  2. Query -- User types in the input, triggering SEARCH_CHANGE
  3. Scoring -- The search engine (TypeScript or WASM) scores each item against the query
  4. Frecency boost -- If enabled, frecency bonuses are added to search scores
  5. Filtering -- Items with score 0 or false are excluded
  6. Sorting -- Results are sorted by final score (descending)
  7. Grouping -- Results are partitioned into groups based on groupId
  8. Rendering -- React adapter renders visible items, virtualizing if needed

Virtualization is latched, not a bare threshold

The filteredCount > 100 test in the diagram above is the turn-on edge. Once virtualization engages it stays engaged until the count falls to 50 or below. That hysteresis band stops a query hovering around 100 results from thrashing the list between virtualized and full-DOM rendering on every keystroke. An explicit virtualize prop on <Command.List> overrides both edges.

Component Tree ​

WASM Worker Communication ​

When SharedArrayBuffer is available (cross-origin isolated), scores are written directly into shared memory. The main thread reads the Float32Array view without any data copying.

Without SharedArrayBuffer, results are transferred via structured clone (postMessage), which involves a copy but remains fast for typical result set sizes (< 100 items).

Plugin Architecture ​

Search Engine Interface ​

Any object implementing SearchEngine can replace the built-in TypeScript scorer:

typescript
import type { CommandItem, ItemId, SearchResult } from 'modern-cmdk';

interface SearchEngine extends Disposable {
  index(items: readonly CommandItem[]): void;
  search(query: string, items: readonly CommandItem[]): IteratorObject<SearchResult>;
  remove(ids: ReadonlySet<ItemId>): void;
  clear(): void;
  [Symbol.dispose](): void;
}

SearchEngine extends Disposable

[Symbol.dispose]() is required, not optional -- a custom engine without it will not typecheck. The machine disposes its engine when the machine itself is disposed, so this is where you release indexes, workers, or WASM instances.

Frecency Storage Interface ​

Custom storage backends implement FrecencyStorage:

typescript
import type { FrecencyData } from 'modern-cmdk';

interface FrecencyStorage extends Disposable {
  load(namespace: string): FrecencyData | Promise<FrecencyData>;
  save(namespace: string, data: FrecencyData): void | Promise<void>;
  [Symbol.dispose](): void;
}

Built-in implementations:

  • MemoryFrecencyStorage -- In-memory Map, no persistence (default)
  • IdbFrecencyStorage -- IndexedDB via idb-keyval, persists across sessions

Extending the Architecture ​

To add a custom search engine:

typescript
import { createCommandMachine, type SearchEngine } from 'modern-cmdk';

const myEngine: SearchEngine = {
  index(items) { /* build your index */ },
  *search(query, items) { /* yield SearchResult objects */ },
  remove(ids) { /* remove from index */ },
  clear() { /* clear index */ },
  [Symbol.dispose]() { /* release the index, worker, or WASM instance */ },
};

using machine = createCommandMachine({ search: myEngine });

To add a custom frecency storage:

typescript
import type { FrecencyData, FrecencyStorage } from 'modern-cmdk';

class RedisFrecencyStorage implements FrecencyStorage {
  async load(namespace: string): Promise<FrecencyData> {
    /* fetch from Redis */
    return { records: new Map() };
  }
  async save(namespace: string, data: FrecencyData): Promise<void> { /* write to Redis */ }
  [Symbol.dispose]() { /* close connection */ }
}

Build Output ​

packages/modern-cmdk/tsdown.config.ts holds three tsdown configs:

  1. The core (dist/core/index.mjs, with declarations).
  2. The React adapter: two entries, modern-cmdk/react and modern-cmdk/react/dialog, in one config so the code both use (context, hooks, CommandDialog) is emitted once as a shared chunk in dist/react. The core is external: the adapter imports it as modern-cmdk, so a bundle holds one core, not a second copy inlined into the adapter. radix-ui stays external too, and the two React outputs (react/index.mjs, react/dialog.mjs) begin with 'use client': the banner comes from this config, and no file under src/react carries the directive. styles.css is copied to dist/react/styles.css.
  3. The codemod (dist/codemod): the deprecated modern-cmdk bin. Its source lives in the sibling package packages/modern-cmdk-codemod (bin modern-cmdk-codemod); this config builds cli.mjs, the worker and the transforms from that source, bundled with jscodeshift and its parser tree (ten packages), behind the shim dist/codemod/bin.mjs, which warns once and keeps the 1.x exit codes and 1.4's excludes until 2.0. modern-cmdk ships THIRD-PARTY-NOTICES.md for the bundled code (pnpm check:notices). Neither modern-cmdk nor modern-cmdk-codemod ships declarations for the codemod, because it is a CLI and not an importable API.

sideEffects in packages/modern-cmdk/package.json is ["./dist/react/styles.css"] only. Command.Dialog is a member of the Command compound, so every modern-cmdk/react import loads radix-ui, and radix-ui is a required peer dependency (>=1.4.0 <2.0.0) through 1.x. See Server-Side Rendering for the measured consequence for bundle size.

Language Features & Editions ​

FeatureEditionWhere UsedNative/Helper
Iterator Helpers (.map, .filter, .toArray)ES2025Search pipelines, registry operationsNative
Symbol.dispose / Symbol.asyncDispose (using / await using)ESNext (TS lib esnext.disposable)Machine, engine and storage lifecycle implement the protocol, so consumers can write usingNative
Promise.withResolvers()ES2024Worker request tracking in the unpublished modern-cmdk-search-wasm packageNative
RegExp.escapeES2025Not used--
Map.groupBy / Object.groupByES2024Shortcut conflict detection, item groupingHelper functions
Date.now()ES5Frecency timestamps, state lastUpdatedNative (replaces Temporal)
Set methods (setIntersection, etc. are the helpers)ES2025Registry bulk operationsHelper functions
String.prototype.toWellFormedES2024Branded ID creationHelper function
Branded types (ItemId, GroupId)TypeScriptType-safe ID handlingCompile-time (erased)
satisfiesTypeScriptDefault configuration validationCompile-time (erased)