Best Practices
Code structure
Keep a consistent order inside setup() so components read predictably:
- Variable definitions: atoms, constants, cached values
- Methods: named functions for reusable logic
- Event handlers:
ctx.on()calls - Effects:
ctx.effect()subscriptions - Return mixin: the optional object returned from setup
define("x-search")
.withProps((p) => ({ query: p.string() }))
.withRefs((r) => ({ input: r.one("input"), results: r.one("ul") }))
.setup((ctx) => {
// 1. Variables
const $filtered = computed(ctx.props.$query, (q) => filterItems(q));
// 2. Methods
function clearSearch() {
ctx.props.$query.set("");
}
// 3. Event handlers
ctx.on(ctx.refs.input, "input", (e) => {
ctx.props.$query.set(e.currentTarget.value);
});
// 4. Effects
ctx.effect($filtered, (items) => {
renderList(ctx.refs.results, tpl, {
data: items,
key: (item) => item.id,
update: (el, item) => { el.textContent = item.name; },
});
});
// 5. Return mixin
return { clearSearch };
});
Refs over manual selection
Prefer withRefs() for elements you always need. Use ctx.getElement() / ctx.getElements() only for dynamic queries (e.g. inside renderList() update callbacks):
// Good: static ref
.withRefs((r) => ({ trigger: r.one("button") }))
// Good: dynamic query inside renderList update
update: (el, item) => {
ctx.getElement(el, ".name").textContent = item.name;
}
Reactive state with atoms
When state changes over time, use Nano Stores atoms instead of local let variables. Atoms integrate with ctx.effect() and ctx.bind(), keeping updates declarative:
// Avoid: imperative variable + manual DOM update
let count = 0;
ctx.on(ctx.refs.btn, "click", () => {
count++;
ctx.refs.display.textContent = String(count);
});
// Prefer: atom + effect
const $count = atom(0);
ctx.on(ctx.refs.btn, "click", () => {
$count.set($count.get() + 1);
});
ctx.effect($count, (count) => {
ctx.refs.display.textContent = String(count);
});
The atom approach scales better: multiple effects can react to the same state, and the current value is always accessible via .get().
Effects over imperative handlers
When a DOM update depends on state, express it as an ctx.effect() rather than scattering updates across event handlers. Effects make the data flow explicit: state changes in one place, the DOM reacts in another:
// Avoid: updating DOM inside the handler
ctx.on(ctx.refs.toggle, "click", () => {
const next = !ctx.props.$open.get();
ctx.props.$open.set(next);
ctx.host.setAttribute("aria-expanded", String(next));
ctx.refs.body.hidden = !next;
});
// Prefer: handler changes state, effect updates DOM
ctx.on(ctx.refs.toggle, "click", () => {
ctx.props.$open.set(!ctx.props.$open.get());
});
ctx.effect(ctx.props.$open, (open) => {
ctx.host.setAttribute("aria-expanded", String(open));
ctx.refs.body.hidden = !open;
});
Components Communication
Parents pass data down through props. Children notify parents via custom events (ctx.emit() / ctx.on()). When a child needs ongoing access to parent state, use the context protocol (nanotags/context). Unrelated components share Nano Stores atoms directly.
Parent to child
The primary channel. A parent sets attributes or properties on its children, and each child reacts via its own prop stores:
// Parent sets attribute, child's $mode atom updates automatically
childEl.setAttribute("mode", "dark");
// Or via property
childEl.mode = "dark";
Child to parent
Standard DOM events. The child dispatches with ctx.emit(), the parent listens with ctx.on():
// Child
ctx.emit("tab:select", { index: 2 });
// Parent
ctx.on(ctx.refs.tabs, "tab:select", (e) => {
console.log(e.detail.index); // 2
});
Child needs parent state or API
Use the Context protocol. The parent exposes a value via provide(), descendants receive it via consume() or withContexts(). This avoids tight coupling and works regardless of DOM depth.
When components form a logical group (Tabs/Tab, Accordion/Panel), the parent provides a typed API and children declare required contexts:
import { createContext } from "nanotags/context";
type TabsAPI = { register: (el: Element) => void; $active: WritableAtom<string> };
const tabsContext = createContext<TabsAPI>("tabs");
const XTabs = define("x-tabs").setup((ctx) => {
const $active = atom("");
tabsContext.provide(ctx, {
$active,
});
});
define("x-tab-panel")
.withProps(p => ({ value: p.string() }))
.withContexts({ tabs: tabsContext })
.setup((ctx) => {
ctx.effect(ctx.contexts.tabs.$active, (active) => {
ctx.host.setAttribute('aria-)
})
});
withContexts() defers setup until all declared contexts resolve. For dynamic or conditional access, use consume() directly.
Siblings or unrelated components
Share a Nano Stores atom directly. Import the same store in both components and react via ctx.effect():
// shared store (plain module)
export const $theme = atom("light");
// component A
ctx.on(ctx.refs.toggle, "click", () => {
$theme.set($theme.get() === "light" ? "dark" : "light");
});
// component B
ctx.effect($theme, (theme) => {
ctx.host.dataset.theme = theme;
});
Combining patterns
You can provide a Nano Stores atom through the Context protocol so that siblings under the same parent share state without a global import:
const filterCtx = createContext<WritableAtom<string>>("filter");
define("x-filter-panel").setup((ctx) => {
const $filter = atom("");
filterCtx.provide(ctx, $filter);
});
// child A writes to the store
define("x-search-input")
.withRefs((r) => ({ input: r.one("input") }))
.withContexts({ filter: filterCtx })
.setup((ctx) => {
ctx.on(ctx.refs.input, "input", (e) => {
ctx.contexts.filter.set(e.currentTarget.value);
});
});
// sibling B reacts to changes
define("x-results-list")
.withContexts({ filter: filterCtx })
.setup((ctx) => {
ctx.effect(ctx.contexts.filter, (query) => {
// filter visible items
});
});
Context API
The Context API enables cross-component communication for parent-child relationships without tight coupling. It’s imported from the separate nanotags/context entry point (~0.4 KB).
When to use context
Use context when a child component needs ongoing access to parent state or API, not just a one-time value (use props) or a fire-and-forget notification (use events).
How it works
The protocol uses two DOM events following the Web Components Community Context Protocol:
Normal case (parent connects first):
provide()registers acontext-requestevent listener on the hostconsume()dispatches acontext-requestevent that bubbles up- The provider catches it, stops propagation, and calls the callback with the value
- The callback runs synchronously
Late provider (child upgrades before parent):
- The
consume()dispatch goes unhandled: no provider is listening yet - A lazy document-level handler stores the pending request
- When the parent’s
provide()runs, it dispatches acontext-providerevent - The document handler re-dispatches
context-requestfrom pending consumers, resolving them
This means context works regardless of element upgrade order.
provide vs consume vs withContexts
There are two ways to consume context. Prefer withContexts(); use consume() only when the context is optional.
withContexts() (declarative, preferred): declares required contexts on the builder. Setup is deferred until all contexts resolve:
define("x-tab")
.withContexts({ tabs: tabsCtx })
.setup((ctx) => {
// ctx.contexts.tabs is guaranteed to be available here
ctx.contexts.tabs.register(ctx.host);
});
Use when: the component cannot function without the context value. If a provider never appears, setup never runs and the element stays inert.
consume() (imperative): requests context inside setup. The callback runs when/if the context resolves:
define("x-widget").setup((ctx) => {
// Setup runs immediately, context is optional
tabsCtx.consume(ctx, (tabs) => {
tabs.register(ctx.host);
});
});
Use when: the context is optional; the component should still function without it, or you need to handle the “no provider” case yourself.
Context consumers registered via consume() are automatically cleaned up on disconnect: pending requests are removed from the document-level queue. Providers remove their context-request listener on disconnect.
TypeScript
Both patterns below use TypeScript global augmentation to extend built-in DOM interfaces.
Augmenting HTMLElementTagNameMap
Register your element so that refs (r.one()/r.many()), ctx.getElement(), ctx.getElements(), and standard DOM APIs (querySelector, createElement) return properly typed instances:
declare global {
interface HTMLElementTagNameMap {
"x-my-el": InstanceType<typeof MyEl>;
}
}
const MyEl = define("x-my-el")
.withProps(/* ... */)
.setup(/* ... */);
This also enables typed ref lookups in other components:
r.one("x-my-el"); // typed as InstanceType<typeof MyEl>, validated at runtime
Typed custom events
Use TypedEvent to define type-safe events, then augment HTMLElementEventMap so that ctx.on(), ctx.emit(), and addEventListener are fully typed:
import type { TypedEvent } from "nanotags";
type SelectionChangeEvent = TypedEvent<
InstanceType<typeof XListBox>,
{ selected: string[] }
>;
declare global {
interface HTMLElementEventMap {
"listbox:change": SelectionChangeEvent;
}
}
// Emit (inside x-listbox setup):
ctx.emit("listbox:change", { selected: ["a", "b"] });
// Listen (anywhere in the app):
ctx.on(listboxEl, "listbox:change", (e) => {
e.target; // XListBox instance
e.detail.selected; // string[]
});
Combining both augmentations
For a complete component definition, declare both the element and its events together:
import { define } from "nanotags";
import type { TypedEvent } from "nanotags";
type TabsChangedEvent = TypedEvent<InstanceType<typeof XTabs>, { index: number }>;
declare global {
interface HTMLElementTagNameMap {
"x-tabs": InstanceType<typeof XTabs>;
}
interface HTMLElementEventMap {
"tabs:changed": TabsChangedEvent;
}
}
const XTabs = define("x-tabs")
.withProps((p) => ({ active: p.string("") }))
.setup((ctx) => {
// ...
});
Attachments
Attachments are reusable functions that receive the setup context (ctx) and wire up behavior—effects, event listeners, cleanup—without creating a new component.
Unlike regular helper functions, attachments are lifecycle-aware: because they receive ctx, everything they register via ctx.on(), ctx.effect(), or ctx.onCleanup() is automatically cleaned up when the host component disconnects. A plain helper that calls addEventListener would leak listeners; an attachment never does.
Attachments also compose naturally with the context protocol. An attachment can call consume() to access ancestor state, or accept a context value as a parameter, letting you build reusable behaviors (keyboard navigation, drag handling, focus traps) that participate in the component tree without being components themselves.
Writing your own
An attachment is just a function, no special API needed. Follow these conventions:
- Accept
ctx: SetupContextas the first parameter - Use
ctx.on(),ctx.effect(),ctx.onCleanup()for auto-cleanup - Accept configuration via additional parameters or an options object
- Optionally return state or methods for the calling component
export function attachClickOutside(
ctx: SetupContext,
callback: () => void,
) {
ctx.on(document, "click", (e) => {
if (!ctx.host.contains(e.target as Node)) callback();
});
}
Example: roving focus
Arrow-key navigation through a group of focusable elements:
export function attachRovingFocus(
ctx: SetupContext,
container: HTMLElement,
items: HTMLElement[],
options: { onFocus?: (el: HTMLElement) => void } = {},
) {
function setActive(index: number) {
items.forEach((item, i) => {
item.setAttribute("tabindex", i === index ? "0" : "-1");
});
}
setActive(0);
ctx.on(container, "keydown", (e) => {
const current = items.indexOf(document.activeElement as HTMLElement);
if (current === -1) return;
let next = -1;
if (e.key === "ArrowRight") next = (current + 1) % items.length;
if (e.key === "ArrowLeft") next = (current - 1 + items.length) % items.length;
if (e.key === "Home") next = 0;
if (e.key === "End") next = items.length - 1;
if (next !== -1) {
e.preventDefault();
setActive(next);
items[next].focus();
options.onFocus?.(items[next]);
}
});
}
Usage:
define("x-tabs")
.withRefs((r) => ({ tablist: r.one("div"), tabs: r.many("[role=tab]") }))
.setup((ctx) => {
attachRovingFocus(ctx, ctx.refs.tablist, ctx.refs.tabs, {
onFocus: (el) => activate(el.dataset.value),
});
});