Reactivity
Client interactivity comes from Datastar. camelon compiles a typed set of JSX props — on:, bind:, signals, text — to Datastar data-* attributes at build time. Set jsxImportSource: "camelon" in your tsconfig to turn it on.
export default function Counter() {
return (
<div class="d-row" signals='{"count": 0}'>
<button class="d-btn" on:click="@post('/counter/decrement')">−</button>
<output class="d-num" text="$count">0</output>
<button class="d-btn" on:click="@post('/counter/increment')">+</button>
</div>
);
}import type { PostFunction } from 'camelon';
export type PostInput = { signals: { count: number } };
export const post: PostFunction<PostInput> = ({ stream, signals }) => {
stream.patchSignals({ count: signals.count + 1 });
};import type { PostFunction } from 'camelon';
export type PostInput = { signals: { count: number } };
export const post: PostFunction<PostInput> = ({ stream, signals }) => {
stream.patchSignals({ count: signals.count - 1 });
};The buttons @post to two resource routes. Each reads the current count off the posted signals and patches the new value back over SSE. The increment.ts and decrement.ts tabs are the whole server side.
Props
| You write | Compiles to |
|---|---|
signals='{"count": 0}' |
data-signals='{"count": 0}' |
text="$count" |
data-text="$count" |
bind:name |
data-bind:name (two-way bind input ↔ $name) |
on:click="@post('/x')" |
data-on:click="@post('/x')" |
on:input__debounce-300ms |
data-on:input__debounce.300ms |
Write dotted modifiers with -. Raw data-* props pass through untouched.
bind: keeps an input and a signal in sync:
export default function Greet() {
return (
<div class="d-card" signals='{"name": ""}'>
<input class="d-input" bind:name="" placeholder="your name" />
<p>Hello <span text="$name">friend</span>!</p>
</div>
);
}Server patches
A resource route answers @post/@get over the stream arg. Patch signals or whole elements. Every patched fragment needs a stable id.
import type { PostFunction } from 'camelon';
export type PostInput = { signals: { count: number } };
export const post: PostFunction<PostInput> = ({ stream, signals }) => {
stream.patchSignals({ count: signals.count + 1 });
};
No stream.close() — the stream closes on its own when the handler returns.
signals holds the current client state. A Datastar @post also sends it as the JSON body, so one PostInput type validates a form submit and a Datastar submit.
A one-shot patch like this fits post. A long-lived stream — one that keeps patching for seconds — must be a get: the response sets cookies before the stream body opens, and only the GET lane allows that ordering. The whole-element streaming demo below is a @get for exactly this reason.
Whole-element patches work the same way — a @get route streams each word into a stable id, one SSE frame at a time:
export default function Stream() {
return (
<div class="d-card">
<button class="d-btn d-wide" on:click="@get('/stream/words')">Stream</button>
<p id="out"></p>
</div>
);
}import type { GetFunction } from 'camelon';
async function* words(): AsyncGenerator<string> {
for (const word of 'words stream in one at a time'.split(' ')) {
await new Promise((r) => setTimeout(r, 130));
yield word;
}
}
export const get: GetFunction = async ({ stream }) => {
stream.patchElements('<p id="out"></p>'); // reset
for await (const word of words()) {
stream.patchElements(`<span> ${word}</span>`, {
selector: '#out',
mode: 'append',
});
}
};Datastar 1.0.2:
data-on-loadis inert. Usedata-effectfor run-on-load and long-lived SSE connections.