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

islands/counter.tsx
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) runs fn now, 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, onInput and every on… add a listener.
  • class and className both 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>) from sluurp/ui. It keeps each row’s element as the list changes, instead of drawing them all again.
  • An async component 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.html that 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.

ReactSluurp
A componentruns again on every changeruns once
What updatesa virtual tree, diffed against the lastthe exact text or attribute that read the signal
StateuseState, useReducersignal, store
Derived valuesuseMemocomputed
Side effectsuseEffect, with a dependency listeffect, whose dependencies are what it reads
Rules of hooksyesnone: create signals anywhere, in ifs and loops
Lists.map with keyeach, which keeps rows as the list changes
Builda bundler and a dev servernone: 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) becomes signal(x), read as count().
  • useEffect becomes effect, and useMemo becomes computed.
  • useRef usually becomes ref={(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.