The UI framework
Sluurp's UI is written in JSX and TypeScript, served as they are: no React, no bundler, no build step. A component runs once and returns real DOM. What changes is a signal, and a signal updates exactly the text or attribute that reads it.
A component
import { signal } from "sluurp/reactive";
export default function Counter({ start = 0 }) {
const count = signal(start);
return (
<button class="rounded-md border px-3 py-1.5" onClick={() => count.set(count() + 1)}>
Clicked {count} times
</button>
);
}Counter runs once. count is a signal: read it by calling it, count(), and change it with count.set(…). Put it in JSX as {count} and only that text node changes when it does. Nothing else runs again: not the component, not the button.
The server compiles .tsx and .jsx as it serves them, erasing the types and turning elements into calls to Sluurp’s JSX runtime, which builds the DOM directly. Nothing is installed and nothing is bundled while you develop.
Signals
import { computed, effect, signal } from "sluurp/reactive";
const first = signal("Ada");
const last = signal("Lovelace");
const full = computed(() => `${first()} ${last()}`); // worked out again only when first or last changes
effect(() => console.log("now", full())); // runs again whenever full changes
first.set("Augusta");signal(value)is a value that can change.computed(fn)is a value worked out from other signals. It’s kept until one of them changes.effect(fn)runsfnnow, then again whenever a signal it read changes.untrack(fn)reads signals without depending on them.onCleanup(fn)runs when the effect or component it belongs to is disposed.
Rows from your data are signals too: live and Records with live keep a query current, and a change to one row updates only what reads that row.
What goes in JSX
- A signal, or any function, in a child or an attribute is reactive:
{count},class={() => (open() ? "block" : "hidden")},disabled={() => !ready()}. - Anything else is used once, as it is.
onClick,onInputand everyon…add a listener.classandclassNameboth work, so code pasted from React keeps working.ref={(el) => …}is called with the element once it exists.- A list is drawn with
each(items, (item) => item.id, (item) => <li>{item.name}</li>)fromsluurp/ui. It keeps each row’s element as the list changes, instead of drawing them all again. - An
asynccomponent can wait for its data. On a page drawn on the server it’s waited for before the page is sent; in the browser, a placeholder holds its place until it’s ready.
When things end
A component’s elements are ordinary DOM. When something must stop once they’re gone, such as a listener on the window, a timer or an observer, clean it up when the element leaves the page:
import { onDetached } from "sluurp/primitives";
<div ref={(el) => {
const tick = setInterval(update, 1000);
onDetached(el, () => clearInterval(tick));
}} />onDetached also cleans up after an element that was made but never shown. In development, the UI kit warns you about a component that leaves anything running.
Pages, islands and apps
The same components work in three places:
- Pages drawn on the server (
routes/*.tsx, and Markdown): HTML is sent, with no JavaScript unless the page has islands. See Server components and islands. - Islands: components on a server page that wake up in the browser. They’re the only JavaScript those pages send.
- Apps drawn in the browser: an
index.htmlthat mounts a component, as MikroSchool does.
Compared with React
If you know React, you know most of this already: JSX, components as functions, props, className. The difference is when code runs.
| React | Sluurp | |
|---|---|---|
| A component | runs again on every change | runs once |
| What updates | a virtual tree, diffed against the last | the exact text or attribute that read the signal |
| State | useState, useReducer | signal, store |
| Derived values | useMemo | computed |
| Side effects | useEffect, with a dependency list | effect, whose dependencies are what it reads |
| Rules of hooks | yes | none: create signals anywhere, in ifs and loops |
| Lists | .map with key | each, which keeps rows as the list changes |
| Build | a bundler and a dev server | none: the server compiles as it serves |
So is it “React with signals”? Close, but it’s nearer to SolidJS: React’s syntax with fine-grained signals, and no virtual DOM. Unlike Solid, there’s no compiler of its own. Reactivity comes from passing a function, and the JSX is plain.
Porting React code is usually direct:
useState(x)becomessignal(x), read ascount().useEffectbecomeseffect, anduseMemobecomescomputed.useRefusually becomesref={(el) => …}.- Code that relied on the component running again should read signals inside functions instead:
{() => …}in JSX.
Two-way binding
Form fields can follow a signal both ways, typed into and set from code. See Two-way binding in the UI kit.
Styling
Tailwind works out of the box, with cn to join and merge classes. See Styling and Tailwind.