| name | common-pitfalls |
| description | Cross-cutting mistakes that recur across rikka code generation. Load this BEFORE generating rikka code, and refer back when debugging. Covers the 4 rikka-specific LLM footguns (signal vs `.get()`, plain function vs `computed`, `this.$xxx` vs `this.count` in render, `events` as transform functions) plus `h\`\`` interpolation modes, `NumberAttr` default `NaN`, and more. |
Common Pitfalls
Cross-cutting mistakes that recur across rikka code generation. Read this before generating rikka code; refer back when debugging.
The four rikka-specific footguns
1. Passing .get() to DOM (loses reactivity)
const count = signal(0);
p({}, count.get());
p({}, count);
.get() returns a plain value. The DOM cannot track it. Pass the signal.
Inside computed / effect callbacks, you must use .get(). That's how dependency tracking works:
computed(() => count.get() * 2)
2. Plain function where computed is required
This is the #1 LLM error. A function and a computed look identical but behave differently in value position:
div({}, () => `count=${count.get()}`);
div({ style: { color: () => count.get() % 2 ? "red" : "blue" } });
div({ style: { color: computed(() => count.get() % 2 ? "red" : "blue") } });
Rule of thumb:
- Child position (inside
h() or tag helpers): function → auto-wrapped, fine.
- Value position (attribute value, prop value, argument to another function): function → static, use
computed().
3. this.count (coarse-grained) vs this.$count (fine-grained) in render()
render is wrapped in computed at runtime. this.count calls .get() on the underlying signal, so it IS tracked — the render re-runs when the attribute changes. The old "static snapshot" footgun is gone. The difference is now granularity, not correctness:
defineElement("my-el", {
attributes: { count: NumberAttr },
render() {
p({}, this.$count);
p({}, this.count);
},
});
Prefer this.$count for DOM bindings. Use this.count for logic where re-rendering is acceptable.
4. this in render / methods is any (two-argument form)
TypeScript cannot infer the config generic C from inside a function body that uses this. With defineElement(tag, { render() { ... } }), this is typed as any, so the body is not type-checked.
defineElement("my-el", {
attributes: { label: StringAttr },
render() {
return span(this.label);
},
});
For strict this typing, use the builder form:
defineElement("my-el")
.attrs({ label: StringAttr })
.render(function () {
return span(this.label);
})
.build();
5. events value must be a transform function, not a type marker
events: { click: MouseEvent }
events: { click: (e: MouseEvent) => ({ x: e.clientX, y: e.clientY }) }
events: { reset: undefined }
Signal interpolation modes (h\``)
| Syntax | Mode | When to use |
|---|
${signal} | Fine-grained | Default. Only the text/attr node updates. Preserves focus, cursor, scroll. |
${signal.get()} | Lost reactivity | When you intentionally want a static value. |
computed(() => h\...`)` | Coarse-grained | When the template structure itself depends on a signal. |
const count = signal(0);
h`<span>Count: ${count}</span>`;
h`<span>Count: ${count.get()}</span>`;
const tmpl = computed(() => h`<span>Count: ${count.get()}</span>`);
h\`returnsElement[]`, not a single element
document.body.appendChild(h`<div>Hello</div>`);
document.body.appendChild(h`<div>Hello</div>`[0]);
For defineElement templates, you need a real HTMLTemplateElement — h\`[0]`.
NumberAttr default is NaN
Number(undefined) returns NaN. If you want a default of 0, say so:
attributes: {
count: { ...NumberAttr, default: 0 },
}
Or use a custom toProp:
attributes: {
count: { toProp: (v) => (v !== undefined ? Number(v) : 0) },
}
render() must return an Element
render() { return "<p>Hello</p>"; }
render() { return p({}, "Hello"); }
template and render are mutually exclusive
defineElement("my-el", {
template: h`<template>...</template>`[0],
render() { return div(); },
});
connectedCallback runs render exactly once
Moving the element in the DOM does not re-render. If you need updates on attribute change, use template with {{name}} bindings (auto-reactive) or wire up effects manually.
HTML/SVG name-collision tags
In an SVG context, use the svg-prefixed version for a, script, style, title, text, span, textPath:
svg(svga({ href: "#x" }, "Link"), svgtext({ x: 10, y: 30 }, "Text"));
The unprefixed names (a, script, …) create HTML elements. See ../svg/.
Don't use innerHTML
It bypasses the signal system, has XSS risk, and prevents binding:
el.innerHTML = `<div>${title}</div>`;
el.replaceChildren(div({}, title));
Don't bind directly to a computed signal
Signal.Computed has no .set(). For two-way binding (e.g. inputs), use a writable Signal.State plus a computed for derived values:
input({ value: computed(() => "hello") });
const text = signal("");
input({ value: text });
const len = computed(() => text.get().length);
Avoid state in composable functions
A plain function component cannot hold reactive state across renders — its locals are re-created on every call:
const Counter = () => {
let count = 0;
return div(p({}, count), button({ onclick: () => count++ }, "+"));
};
const Counter = () => {
const count = signal(0);
return div(p({}, count), button({ onclick: () => count.set(count.get() + 1) }, "+"));
};
For real lifecycle (mount / unmount / re-render on attribute change), use defineElement.
customElements.define ordering
rikka uses queueMicrotask to defer registration, so multiple defineElement calls in the same module work. But the module must be loaded before any document.createElement("my-el") runs.
If the defining module loads asynchronously, custom elements may briefly exist as plain HTMLElements with no .$xxx accessors. Import the defining module at the top of the entry file, or use dynamic import() before creating instances.
rikka-site specific pitfalls
defineElement() already calls customElements.define()
Don't call customElements.define() again for elements created with defineElement() — it throws NotSupportedError: "the name X has already been used":
const MyEl = defineElement("my-el", { ... });
customElements.define("my-el", MyEl);
const MyEl = defineElement("my-el", { ... });
If you need a safe re-registration function (e.g., for testing), guard with customElements.get():
function register(name: string, ctor: CustomElementConstructor) {
if (!customElements.get(name)) customElements.define(name, ctor);
}
data-path is a plain attribute, not reactive
rikka-site sets data-path on your layout element as a plain HTML attribute. It's not declared in your attributes config and won't be reactive:
defineElement("blog-layout", {
attributes: { siteName: StringAttr, currentPath: StringAttr },
render(this) {
const actualPath = this.getAttribute("data-path") ?? "/";
const pathSignal = signal(actualPath);
},
});
Trailing slashes in paths
Server URLs may have trailing slashes (/articles/1/). Always normalize before routing:
const normPath = (this.getAttribute("path") ?? "").replace(/\/+$/, "");
Route specificity order matters
When writing client-side routers, match more specific paths before less specific ones:
if (normPath.match(/^\/articles\/\d+$/))
return document.createElement("article-detail");
if (normPath.startsWith("/articles"))
return document.createElement("article-list");
if (normPath.startsWith("/articles"))
return document.createElement("article-list");
Data format differs between data-resource and JSON-LD
data-resource attribute: raw array [...] or object {...}
- JSON-LD
<script>: wrapped in { "@graph": [...] } for collections
Handle both formats defensively in your hydration code:
const rawData = findResourceData(this);
const items = signal(
Array.isArray(rawData) ? rawData : (rawData?.["@graph"] ?? [])
);
See also