Components
Elur has two ways to write a component, both built on the same instance, lifecycle, SSR and hydration machinery:
defineComponent(setup)— functional components with typed, live props and slots. Recommended for new code.ElurComponent— class-based components with lifecycle overrides.
In both models the component body runs once per instance — there is no re-render. Updates flow through signals, not through re-invoking the component.
# defineComponent(setup)
defineComponent takes a setup function that receives live props and a
ctx, and returns a template. Calling the resulting definition creates an
invocation — the setup itself doesn't run until the invocation is
mounted:
import { defineComponent, signal, html, mount } from "@elurjs/core";
const Counter = defineComponent<{ initial: number }>((props, ctx) => {
const count = signal(props?.initial ?? 0);
return html`
<button @click=${() => count.update((n) => n + 1)}>
Count: ${() => count.value}
</button>
`;
});
mount(Counter({ initial: 10 }), "#app");Counter({ initial: 10 }) doesn't render anything — it creates a marked
invocation object. The runtime runs setup when the invocation mounts,
which is what gives every instance its own owner, lifecycle and cleanup
tree.
Invocations are valid anywhere a template is: inside html bindings, in
repeat, in showWhen, in slots, in SSR and in hydration.
# Live props
Props are live: every prop getter is backed by a signal. When the parent
re-evaluates a binding and produces a new invocation for the same
component, Elur patches the props in place — bindings that read
props.x update, and setup never re-runs:
const Badge = defineComponent<{ label: string }>((props) => {
// props.label is a getter backed by a signal
return html`<span class="badge">${() => props!.label}</span>`;
});
const App = defineComponent(() => {
const name = signal("Ana");
// each evaluation creates a new invocation — same definition,
// so the existing instance keeps its state and only props update
return html`
<div>${() => Badge({ label: name.value })}</div>
<input @input=${(e: Event) =>
(name.value = (e.target as HTMLInputElement).value)} />
`;
});Because props are getters, destructure only what you read eagerly — values
captured in a local (const l = props.label) lose reactivity. Read
props.x inside bindings (${() => props.x}) or computeds so updates
propagate.
For explicit reactive access, ctx.propSignal("label") returns the
underlying Signal.
# The key prop
key is identity, not a reactive prop. When a binding produces a new
invocation, Elur reconciles by definition + key:
- Same definition, same key →
updatePropsin place (state preserved). - Same definition, different key → the instance is remounted.
- Different definition → remounted.
// switching keys forces a fresh instance — the old one unmounts cleanly
${() => UserCard({ id: userId.value, key: userId.value })}# Lifecycle
Functional components register lifecycle through ctx instead of class
overrides:
ctx hook |
When it runs | DOM available? |
|---|---|---|
ctx.onMount(fn) |
After the template is inserted into the DOM | Yes |
ctx.onUnmount(fn) |
Before removal; also runs onMount's cleanup |
Yes |
ctx.onError(fn) |
When setup or onMount throws |
— |
ctx.onServerRender(fn) |
During SSR only, never on the client | No |
ctx.onMount runs after the DOM commit and may return a cleanup
function, which fires on unmount:
const Timer = defineComponent((_props, ctx) => {
const seconds = signal(0);
ctx.onMount(() => {
const id = setInterval(() => seconds.update((n) => n + 1), 1000);
return () => clearInterval(id); // cleanup on unmount
});
ctx.onUnmount(() => console.log("Timer removed"));
return html`<p>Elapsed: ${() => seconds.value}s</p>`;
});Everything created inside setup — signals, effects, computeds — is owned by the component instance and disposed on unmount. Manual cleanup is only needed for resources outside the reactive graph (timers, listeners, sockets).
# Slots
A consumer passes slot content as the second argument of the
invocation: a record of slot(() => ...) markers. Inside the component,
ctx.slot(name) returns the marked Slot, and the template renderer
unwraps it:
import { defineComponent, slot, html } from "@elurjs/core";
const Card = defineComponent((_props, ctx) => {
return html`
<div class="card">
<div class="card-body">${() => ctx.slot()}</div>
<div class="card-actions">${() => ctx.slot("actions")}</div>
</div>
`;
});
mount(
Card({}, {
default: slot(() => html`<p>Card body</p>`),
actions: slot(() => html`<button>Save</button>`),
}),
"#app",
);# Lexical owner
Slots render under the owner of the parent that declared them, not the
component that mounts them. That means inject() inside slot content
resolves the declaring parent's context — the behavior you'd expect from
nested JSX:
const Wrapper = defineComponent((_p, ctx) => {
provide(ThemeKey, "from-wrapper");
return html`<div class="wrap">${() => ctx.slot()}</div>`;
});
const Root = defineComponent(() => {
provide(ThemeKey, "from-root");
return html`
<main>
${Wrapper({}, {
// resolves "from-root", not "from-wrapper"
default: slot(() => Child({})),
})}
</main>
`;
});ctx.slots exposes the declared record if you need to inspect or forward
slots. ctx.slot() with no argument reads the "default" slot.
# Context and DI
provide() / inject() work inside setup — the instance is the owner, so
injected values resolve against the component's ancestors:
const UserMenu = defineComponent((_props, ctx) => {
const session = inject(SessionKey); // from an ancestor
return html`<span>${() => session.user.name}</span>`;
});ctx.owner exposes the instance owner for advanced cases
(runWithOwner, getOwner).
# Mounting
An invocation mounts anywhere a template does. For a root mount, mount()
accepts invocations directly, and mountComponent() additionally returns
the instance:
import { mount, mountComponent } from "@elurjs/core";
mount(App({}), "#app");
const { unmount, instance } = mountComponent(App({}), container);
unmount(); // disposes the whole reactive treeUnmounting is idempotent and tears down the instance's entire owner tree — effects, computeds, cleanups and child components.
# SSR and hydration
Functional components render on the server and hydrate on the client with no extra wiring — the invocation knows how to serialize itself and how to adopt SSR'd DOM:
const Clock = defineComponent((_p, ctx) => {
ctx.onServerRender(() => {
// server-only: seed data, read request scope
});
return html`<time>${() => new Date().toLocaleTimeString()}</time>`;
});ctx.onServerRender runs during SSR (after setup, before serialization)
and never on the client — the server-side equivalent of onMount.
# Class components
The class model is still fully supported and shares the same machinery. See Lifecycle for the full hook table:
class Counter extends ElurComponent {
private count = signal(0);
override onMount() {
const id = setInterval(() => this.count.update((n) => n + 1), 1000);
return () => clearInterval(id);
}
override render() {
return html`<button>${() => this.count.value}</button>`;
}
}
mount(new Counter(), "#app");# Which model should I use?
defineComponent |
ElurComponent |
|
|---|---|---|
| Props | Typed, live (signal-backed) | Constructor arguments |
| Slots | slot() markers + ctx.slot() |
setChildren / setSlot |
| Lifecycle | ctx.onMount / onUnmount / onError |
Method overrides |
| Reconciliation | By definition + key, props patch in place | Remount on re-eval |
| Best for | New code, reusable UI, lists, libraries | Existing codebases |
Template factories — plain functions that return html\`— still work and are fine for static composition. Reach fordefineComponent` when you
need props, lifecycle, context, slots or stable identity in lists.