Components
Every .jslop file declares one or more components. A component bundles state, behavior, and markup into a unit you can render and reuse.
Declaring a component
component Hello {
view {
<h1>Hello, world</h1>
}
}
A component block has:
- A name in PascalCase (
Hello,UserCard,PostList). - An optional body of declarations:
prop,state,derived,let,function, in any order. - Exactly one
view { ... }block, with exactly one root element. - Optional
head { ... },style { ... }, and (for routes/layouts)load { ... }andaction name(...) { ... }declarations.headandstyleare covered below;loadis server-side data fetching, documented in Routing →load { ... };actionis the server mutation primitive, documented in Actions.
You can declare as many components as you like in a single file. The first one is the default export, and every component becomes a named export.
// widgets.jslop
component Button {
prop label = "?"
prop onclick = () => {}
view { <button onclick={onclick}>{label}</button> }
}
component Card {
prop title = ""
view { <section><h2>{title}</h2></section> }
}
Consumers can pick either form:
import Button from "./widgets.jslop" // default = first block
import { Button, Card } from "./widgets.jslop" // named
import Default, { Card } from "./widgets.jslop" // both
Sibling components in the same file can reference each other directly — <Card/> inside Button’s view just works.
The five declarations
| Keyword | Reactive | Survives SSR | Use it for |
|---|---|---|---|
prop x |
yes | parent decides | input from a parent component |
state x |
yes | yes | anything the view reads — counters, drafts, lists, toggles |
derived x |
yes (read-only) | recomputed | memoized value derived from state / prop / other derived |
let x |
no | no | per-instance bookkeeping the view never reads (caches, IDs, timers) |
function f |
— | — | event handlers, view helpers, local async work |
action f |
— | server only | route POST endpoints — see Actions |
The next sections cover each, plus the optional head and style blocks.
prop — input from a parent
component Display {
prop value = 0
prop label = "Count"
view {
<p><strong>{label}:</strong> {value}</p>
}
}
<Display value={count} label="Hits" />
<Display value={5} /> // label falls back to "Count"
<Display /> // value falls back to 0
A prop is a reactive input — when the parent changes the value it passed, the child re-renders the parts that depend on it.
The default expression after = is used when the parent omits the prop or passes undefined. If you omit the default entirely, the value is undefined:
prop label = "?" // string default
prop onclick = () => {} // callback default
prop count // no default → undefined
Tip
state — reactive variable
state count = 0
state todos = []
state user = { name: "Ada" }
Mutations look like ordinary JavaScript:
count++
count = count + 1
todos = [...todos, "buy milk"]
user.name = "Lovelace" // ← see callout below
Anywhere in the component that reads count — a view interpolation, a function, an event handler — subscribes to it. When count changes, only those locations re-run.
state values are serialized when the server renders the page and restored on the client. That’s how a counter at 5 on the server lands at 5 in the browser without re-running the whole tree.
Warning
user.name = "x" updates user.name, but the cell user itself didn’t get a new value — so subscribers to user won’t re-run. If a view reads user.name, replace the whole object: user = { ...user, name: "x" }. Same rule for arrays: use arr = [...arr, x], not arr.push(x).derived — memoized reactive value
component Cart {
state items = []
derived count = items.length
derived subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0)
derived tax = subtotal * 0.19
derived total = subtotal + tax
view {
<section>
<p>{count} items · {total.toFixed(2)} €</p>
</section>
}
}
The right-hand side may reference any state, prop, or other derived. The compiler rewrites those identifiers to .get() calls so the expression re-runs only when an input it actually read changes — and the result is cached between reads.
derivedvalues are read-only. The compiler rejects any assignment, compound-assign, or++/--on aderivedat compile time (error: cannot assign to derived 'name'). They’re a function of their inputs — change an input instead.- They are not serialized into the SSR capsule. After hydration, the client recomputes them on first read — same inputs, same output.
- Reach for
derivedwhenever a value is a pure function of other reactives. Inline expressions in the view work too ({items.length}), but aderivedis named, cached, and reusable across the view and anyfunction.
Note
derived(() => ...) function from @jslop/runtime — see Reactivity.let — plain mutable variable
let lastId = 0
let cache = new Map()
let abortCtrl = null
A let is not reactive. It’s a plain JavaScript let binding scoped to the component instance. The view never knows when it changes. It is not serialized in the SSR capsule.
Use it for bookkeeping the view doesn’t read:
component Search {
prop query = ""
state results = [] // view renders this → reactive
let pendingId = 0 // sequence number for race-cancellation → plain JS
async function run() {
const id = ++pendingId
const r = await fetch("/search?q=" + query).then(r => r.json())
if (id === pendingId) results = r // ignore stale responses
}
view {
<ul>{#each results as r (r.id)}<li>{r.label}</li>{/each}</ul>
}
}
Warning
let from a view expression (e.g. <p>{cache.size}</p>), it’s read once at mount and never again. Reach for state whenever the view needs to see the value.You can also use let as a per-instance constant when you don’t want a cell:
let id = crypto.randomUUID()
function — handlers and actions
function increment() {
count++
}
function addTodo() {
if (draft.trim().length > 0) {
todos = [...todos, draft.trim()]
draft = ""
}
}
Inside a function body, identifiers that match a state or prop name are rewritten to read and write through their reactive cell. Local variables, parameters, and let references are left alone.
You don’t have to wrap functions in useCallback — they’re plain functions and JSlop doesn’t re-run the component body. The function reference is stable for the lifetime of the component instance.
Important
function, not fn. fn appears in design notes but the implemented keyword is function.view — the markup
Every component must have exactly one view block with exactly one root element:
view {
<div>
<h1>Title</h1>
<p>Body</p>
</div>
}
What goes inside is covered in detail in Template syntax, Logic blocks, Events, and Bindings.
head — per-component <head> fragment
component PostPage {
prop post = { title: "", excerpt: "" }
head {
<title>{post.title} · Site</title>
<meta name="description" content={post.excerpt}/>
<link rel="canonical" href={"/posts/" + post.slug}/>
}
view {
<article>…</article>
}
}
head contains a fragment of elements destined for the document <head> — <title>, <meta>, <link>, and similar. Reactive interpolations work like anywhere else: <title>{post.title}</title> updates when post changes.
During SSR, every component on the page contributes its head fragment, in render order. The route’s fragment renders after any layouts, so a route-level <title> wins over a layout default. Pure-whitespace text nodes between tags are stripped (they’d otherwise leak into the rendered head).
You can keep using the Vite plugin’s title: url => "..." for a global default; per-component head { ... } takes precedence when present.
style — scoped CSS
component Card {
style {
.row { display: flex; gap: 0.5rem; }
.title { font-weight: 600; }
p { color: #888; }
}
view {
<article class="row">
<h2 class="title">title</h2>
<p>body</p>
</article>
}
}
Each component with a style block gets a unique scope class (e.g. jslop-card-1a2b3c), appended to the root element’s class attribute. Every selector in the block is prefixed with that class, so rules only match elements inside this component’s view.
See Styling for the full mechanics and a discussion of how scoped styles compose with global CSS, Tailwind, and CSS modules.
Putting it together
import { Stepper } from "../components/widgets.jslop"
component TodoList {
prop initial = []
state items = initial
state draft = ""
let nextId = 1
function add() {
const text = draft.trim()
if (!text) return
items = [...items, { id: nextId++, text, done: false }]
draft = ""
}
function toggle(id) {
items = items.map(t => t.id === id ? { ...t, done: !t.done } : t)
}
view {
<section>
<h2>Todos</h2>
<input bind:value={draft} placeholder="what next?" />
<Stepper label="add" onstep={add} />
<ul>
{#each items as t (t.id)}
<li>
<input type="checkbox" bind:checked={t.done} />
<span>{t.text}</span>
</li>
{/each}
</ul>
{#if items.length === 0}
<p>nothing here yet.</p>
{/if}
</section>
}
}
This is the whole authoring surface today. The next pages cover the markup half — what you can put inside view { ... }.
See also
- Template syntax — tags, attributes, interpolation.
- Logic blocks —
{#if},{#each}. - Events —
onclick, inline mutations, component callbacks. - Bindings —
bind:value,bind:checked. - Reactivity — the primitives behind
stateandprop.