Edit on GitHub

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 { ... } and action name(...) { ... } declarations. head and style are covered below; load is server-side data fetching, documented in Routing → load { ... }; action is 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

If the parent passes a reactive cell as a prop, writes to it from inside the child flow back to the parent. Most of the time you don’t need to think about this — it just works.

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

Reactivity tracks assignments, not deep property changes. 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.

  • derived values are read-only. The compiler rejects any assignment, compound-assign, or ++/-- on a derived at 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 derived whenever a value is a pure function of other reactives. Inline expressions in the view work too ({items.length}), but a derived is named, cached, and reusable across the view and any function.

Note

For ad-hoc derived values outside a component (helpers, library code), use the 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

If you reference a 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

Use 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

Last updated: May 15, 2026