---
title: The UI framework
description: "JSX without React: components that run once, real DOM, and signals that update exactly what reads them. How it compares with React and Solid."
section: Frontend
order: 1
---

# The UI framework

<p class="lead">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.</p>

## A component

```tsx title="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

```tsx
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`](/docs/sync) 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:

```tsx
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](/docs/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](/docs/server-components).
- **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.

| | 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 `if`s 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](https://www.solidjs.com): 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](/docs/ui-kit#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](/docs/styling).
