Cookbook

Best Practices

Code structure

Keep a consistent order inside setup() so components read predictably:

  1. Variable definitions: atoms, constants, cached values
  2. Methods: named functions for reusable logic
  3. Event handlers: ctx.on() calls
  4. Effects: ctx.effect() subscriptions
  5. 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):

  1. provide() registers a context-request event listener on the host
  2. consume() dispatches a context-request event that bubbles up
  3. The provider catches it, stops propagation, and calls the callback with the value
  4. The callback runs synchronously

Late provider (child upgrades before parent):

  1. The consume() dispatch goes unhandled: no provider is listening yet
  2. A lazy document-level handler stores the pending request
  3. When the parent’s provide() runs, it dispatches a context-provider event
  4. The document handler re-dispatches context-request from 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:

  1. Accept ctx: SetupContext as the first parameter
  2. Use ctx.on(), ctx.effect(), ctx.onCleanup() for auto-cleanup
  3. Accept configuration via additional parameters or an options object
  4. 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),
    });
  });