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
SearchEngineinterface - 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
CommandStateobjects 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 tearinguseTransition-- Wraps only the optimistic active-item update;Command.Inputkeeps an urgent draft value and the machine catches up on its scheduler tick, soisPendingnever reflects a searchuseOptimistic-- Optimistic active item updates for instant visual feedbackrefas prop -- React 19 native ref forwarding (noforwardRef)"use client"directive -- The shipped React components are client-only:dist/react/index.mjsanddist/react/dialog.mjsbegin with the directive, added as a banner bypackages/modern-cmdk/tsdown.config.ts(the source files undersrc/reactdo 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
SharedArrayBufferzero-copy transfer - Custom storage -- Pluggable
FrecencyStorageimplementations (memory, IndexedDB, custom)
State Machine Flow
Event Processing
- External code calls
machine.send(event) - The machine processes the event synchronously
- A new immutable
CommandStatesnapshot is created - All subscribers are notified with the new state
- React
useSyncExternalStoretriggers a re-render with the new snapshot
Data Flow: Items to Rendered Output
Search Pipeline
- Registration -- Items are registered via
REGISTER_ITEMevents (or React component mount) - Query -- User types in the input, triggering
SEARCH_CHANGE - Scoring -- The search engine (TypeScript or WASM) scores each item against the query
- Frecency boost -- If enabled, frecency bonuses are added to search scores
- Filtering -- Items with score
0orfalseare excluded - Sorting -- Results are sorted by final score (descending)
- Grouping -- Results are partitioned into groups based on
groupId - 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:
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:
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-memoryMap, no persistence (default)IdbFrecencyStorage-- IndexedDB viaidb-keyval, persists across sessions
Extending the Architecture
To add a custom search engine:
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:
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:
- The core (
dist/core/index.mjs, with declarations). - The React adapter: two entries,
modern-cmdk/reactandmodern-cmdk/react/dialog, in one config so the code both use (context, hooks,CommandDialog) is emitted once as a shared chunk indist/react. The core is external: the adapter imports it asmodern-cmdk, so a bundle holds one core, not a second copy inlined into the adapter.radix-uistays 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 undersrc/reactcarries the directive.styles.cssis copied todist/react/styles.css. - The codemod (
dist/codemod): the deprecatedmodern-cmdkbin. Its source lives in the sibling packagepackages/modern-cmdk-codemod(binmodern-cmdk-codemod); this config buildscli.mjs, the worker and the transforms from that source, bundled with jscodeshift and its parser tree (ten packages), behind the shimdist/codemod/bin.mjs, which warns once and keeps the 1.x exit codes and 1.4's excludes until 2.0.modern-cmdkshipsTHIRD-PARTY-NOTICES.mdfor the bundled code (pnpm check:notices). Neithermodern-cmdknormodern-cmdk-codemodships 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
| Feature | Edition | Where Used | Native/Helper |
|---|---|---|---|
Iterator Helpers (.map, .filter, .toArray) | ES2025 | Search pipelines, registry operations | Native |
Symbol.dispose / Symbol.asyncDispose (using / await using) | ESNext (TS lib esnext.disposable) | Machine, engine and storage lifecycle implement the protocol, so consumers can write using | Native |
Promise.withResolvers() | ES2024 | Worker request tracking in the unpublished modern-cmdk-search-wasm package | Native |
RegExp.escape | ES2025 | Not used | -- |
Map.groupBy / Object.groupBy | ES2024 | Shortcut conflict detection, item grouping | Helper functions |
Date.now() | ES5 | Frecency timestamps, state lastUpdated | Native (replaces Temporal) |
Set methods (setIntersection, etc. are the helpers) | ES2025 | Registry bulk operations | Helper functions |
String.prototype.toWellFormed | ES2024 | Branded ID creation | Helper function |
Branded types (ItemId, GroupId) | TypeScript | Type-safe ID handling | Compile-time (erased) |
satisfies | TypeScript | Default configuration validation | Compile-time (erased) |