Conventions
Conventions over configuration. The compiler reads your filenames and destructure patterns and generates the wiring you'd otherwise type by hand.
Components
-
Filename = kebab tag.
src/components/my-counter.jsxregisters. The shorthanddefineComponent((props) => …)infers the tag from the filename — you don't pass a string. -
Defaults from destructuring.
({ count = 0, label = 'x' })makescountandlabelobserved attributes; the compiler injects adefaultsobject and infers types from the default values (number, boolean, string, array, object). -
Co-located CSS. A sibling
.cssfile with the same stem is auto-imported as a constructibleCSSStyleSheetand attached viaadoptedStyleSheets. -
Hoisted styles. Static
children of the component's root JSX are extracted at compile time into one sheet shared across all instances. They don't allocate per-render. - Auto-import. Kebab tags used in JSX auto-import their component file (side-effect). PascalCase tags auto-import as named imports — they're function components, not custom elements.
Pages
Files under src/pages/:
src/pages/index.jsx → /
src/pages/about.jsx → /about
src/pages/blog/index.jsx → /blog
src/pages/blog/[slug].jsx → /blog/:slug
src/pages/[...rest].jsx → catch-all
src/pages/_layout.jsx → wraps every page below it (cascading)
src/pages/_404.jsx → unmatched routes
src/pages/_error.jsx → thrown errors during render or loader
A page module exports:
export default function Page({ data, params }) { ... }
// Optional — runs during SSR and on client-side navigation
export async function loader({ params, url, request }) { ... }
// Optional — mutations dispatched via
export const actions = defineActions({ /* ... */ })
Per-page types
The rogueRouter plugin emits a typed
./ module beside each page. Import
from it to type your loader and actions without repeating the file
path or schema:
// src/pages/post/[id].tsx
import type { LoaderArgs, ActionArgs } from './[id].types'
export const loader = async ({ params }: LoaderArgs) => {
// params is typed { id: string } — derived from the [id] bracket
}
Generated files live under .rogue/types/ (auto-added to
.gitignore). One-time tsconfig.json setting maps them
onto the source tree:
{
"compilerOptions": {
"rootDirs": [".", "./.rogue/types"]
}
}
Page-level CSS goes in a sibling
file imported via Vite (import
'./page.css'), not an inline tag
— inline styles re-parse on every render and lose IDE syntax
highlighting + standalone HMR. The auto-import-sibling-CSS
convention is only for defineComponent custom
elements.
Reactivity
If you're coming from React, the headline difference is: your
component function runs once, when the element mounts. It
doesn't re-run when state changes. Instead, the pieces of the DOM that
depend on state subscribe to it directly, and only those pieces update.
No virtual DOM, no diff, no useMemo / useCallback /
dependency arrays.
The unit of reactivity is a signal: a value that
notifies subscribers when it changes. Anything that reads a signal
while the framework is "watching" — a JSX expression, an
effect, a memo — gets re-run automatically
when the signal's value changes.
signal
signal(initial) creates a piece of state and returns it
as a [read, write] tuple. The read is a function, not a
value — that's how the framework knows when you're reading
(to subscribe you) versus using a stale value.
import { signal } from '@jjordy/rogue'
const [count, setCount] = signal(0)
count() // → 0 (reading subscribes the surrounding effect/JSX)
setCount(1) // notifies subscribers, who re-run
setCount(n => n + 1) // updater form, same as React
Inside JSX, calling the read function inline is the natural pattern — the compiler treats the call as a reactive expression and updates just that DOM node when the signal changes:
<button onClick={() => setCount(count() + 1)}>
clicked {count()} times
button>
effect
effect(fn) runs fn once immediately, then
re-runs it whenever any signal it read during that run changes. It's
the bridge between reactive state and the outside world — DOM event
listeners, timers, console logging, network requests:
import { signal, effect } from '@jjordy/rogue'
const [count, setCount] = signal(0)
effect(() => {
console.log('count is now', count())
})
// → "count is now 0" (runs immediately)
setCount(5)
// → "count is now 5" (re-runs because count() was read above)
Effects also clean up after themselves. If your effect needs to undo
something on the next run (remove an event listener, clear a timer),
register an onCleanup(fn) — it runs before the next
iteration and again on final dispose:
effect(() => {
const t = setInterval(() => setCount(c => c + 1), 1000)
onCleanup(() => clearInterval(t))
})
memo
memo(fn) is a derived signal — it reads other signals
and caches its result until they change. Use it when a computation
is expensive or has multiple readers:
const [items] = signal([1, 2, 3, 4, 5])
const total = memo(() => items().reduce((a, b) => a + b, 0))
total() // → 15, computed once
total() // → 15, returned from cache (items hasn't changed)
Unlike React's useMemo, there's no dependency array —
memo tracks what it reads automatically. Add or remove a
signal inside the function and the dependency set updates itself.
untrack & batch
untrack(fn) reads signals inside fn without
subscribing. Useful when you want a one-time peek at the current
value of a signal without your effect re-running on its changes.
batch(fn) coalesces multiple writes so subscribers run
once at the end of the batch instead of once per write — the same
spirit as React's automatic batching of state updates inside an
event handler.
batch(() => {
setFirstName('Ada')
setLastName('Lovelace')
// any effect that reads both runs exactly once after this block.
})
For — keyed lists
Rendering an array with .map(...) works for static
lists but discards all DOM state if the array changes. Reach for
when items can be added, removed, or
reordered and you want each row's DOM (and any child component
state, focus, scroll, animations) to stick with the underlying
item:
<For each={items} by={(i) => i.id}>
{(item) => <li>{item.label}li>}
For>
by is the key — same role as React's key
prop, but the key is computed from the item rather than written into
JSX. When the array updates, For reuses existing rows
whose key still matches and only creates / removes nodes for the
diff.
onCleanup & Scope
Every reactive lifetime — a component's render, an effect, a
For row — runs inside a Scope. The
scope tracks which signals were read and which cleanups were
registered. When the scope ends (effect re-runs, component
disconnects, row removed from For), every cleanup it
owns fires, and every nested scope it created is disposed too.
For 95% of code you don't think about Scope at all — it just makes
onCleanup work intuitively. The only time it surfaces
is when you need to explicitly create or dispose a sub-scope (rare,
and the API for that lives in the API reference).
Two-way binding
bind:value={tuple} wires both the read (effect that
keeps the DOM property in sync) and the write (event listener that
calls the setter).
const text = signal('')
return <input bind:value={text} />
What you don't need to do
- No import-the-component-file lines for tags you use.
- No
observedAttributesarray — defaults give it to you. - No virtual-DOM keys for static lists.
- No effect-cleanup boilerplate — Scope auto-cascades.
- No special syntax to access the host element — it's the second arg to your render function.