cajoa
v1.0.0
Published
A small, beginner-friendly frontend framework built on signals and JSX.
Maintainers
Readme
Cajoa
A small, beginner-friendly frontend framework built on signals and JSX. No virtual DOM, no hooks rules, no re-rendering components.
Docs: https://cajoats.github.io/Cajoa/
Why Cajoa?
- Components run once. A component is just a function that builds DOM. Only the parts that depend on a signal update.
- Plain JSX. Write
<button onClick={...}>, no special syntax to learn. - Tiny and readable. The core is small enough to read in one sitting.
- Renderer-agnostic core.
@cajoa/corehas no DOM dependency, so other targets (native, SSR) can plug in their own renderer. - TypeScript-first, ESM + CommonJS, zero runtime dependencies.
Get started
The fastest way is the starter:
npm create cajoa@latest my-app
cd my-app
npm install
npm run devOr add Cajoa to an existing project:
npm install cajoaManual setup
Add the plugin to vite.config.ts:
import { defineConfig } from "vite";
import { cajoa } from "cajoa/vite";
export default defineConfig({ plugins: [cajoa()] });And set the JSX types in tsconfig.json:
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "cajoa" } }The plugin also sets Vite's JSX transform for you. Without the plugin, set oxc: { jsx: { runtime: "automatic", importSource: "cajoa" } } (Vite 8) or esbuild: { jsx: "automatic", jsxImportSource: "cajoa" } (older Vite) yourself.
Quick start
import { createSignal, render } from "cajoa";
function Counter() {
const [count, setCount] = createSignal(0);
return (
<button onClick={() => setCount((c) => c + 1)}>
Clicked {count} times
</button>
);
}
render(Counter, document.getElementById("app")!);Requirements
Node.js 20 or newer, Vite 8 for the plugin, TypeScript 5 or newer (if you use TypeScript), and a modern browser (the code is built for ES2022).
Stability
From 1.0, Cajoa follows semantic versioning. The API in the docs is stable, except the parts marked experimental (the Renderer interface, ServerContext and the hydration markers). See Stability and versioning.
Reactive JSX
With the Vite plugin, write plain JSX. Calls to signals are reactive wherever they appear in a DOM element, and Show children are created lazily:
<p>Double: {count() * 2}</p>
<p class={count() % 2 ? "odd" : "even"} style={{ color: color() }}>…</p>
<Show when={count() > 3} fallback={<p>Keep clicking</p>}>
<p>More than 3!</p>
</Show>
<For each={todos()}>{(todo) => <li>{todo.text}</li>}</For>The plugin rewrites your JSX at build time. When an expression contains a function call, it wraps it in a function ({count() * 2} becomes {() => (count() * 2)}) for:
- children and attributes of DOM elements (but not
ref,keyandon*props) - children of your own components
whenandeachofShow,MatchandFor, andisofDynamic- the
fallbackand the children ofShow,ErrorBoundaryandSuspense, the children ofMatchandPortal, and thefallbackofSwitch
It does not change the props of your own components. They receive plain values. To pass a live value, pass the signal itself (<Greeting name={name} />) or a function.
Without the plugin, two rules apply:
- Pass the signal, don't call it.
{count}is reactive,{count()}is read once. For computed values, pass a function:{() => count() * 2}. Showchildren are a function:<Show when={on}>{() => <p>Hello</p>}</Show>.
Lists and conditions
import { createSignal, Show, For } from "cajoa";
const [todos, setTodos] = createSignal([{ id: 1, text: "Learn Cajoa" }]);
<Show when={() => todos().length > 0} fallback={() => <p>Nothing to do.</p>}>
{() => (
<ul>
<For each={todos}>{(todo) => <li>{todo.text}</li>}</For>
</ul>
)}
</Show>;const [selected, setSelected] = createSignal<number | null>(null);
const isSelected = createSelector(selected);
<For each={rows}>
{(row) => <tr class={isSelected(row.id) ? "danger" : ""}>...</tr>}
</For>;Routing
import { render, Router, Route, Outlet, NavLink, useParams } from "cajoa";
function Layout() {
return (
<div>
<nav>
<NavLink href="/" end>
Home
</NavLink>
<NavLink href="/users/7">User 7</NavLink>
</nav>
<Outlet />
</div>
);
}
const User = () => <h1>User {useParams().id}</h1>;
render(
() => (
<main>
<Router>
<Route path="/" component={Layout}>
<Route path="" component={() => <h1>Home</h1>} />
<Route path="users/:id" component={User} />
</Route>
<Route path="*" component={() => <h1>Not found</h1>} />
</Router>
</main>
),
document.getElementById("app")!,
);- Routes are matched in order, so put
path="*"last. Child paths are relative to their parent, andpath=""is the index route. <Outlet />renders the matching child route inside a layout. The layout stays alive while you move between its children.useParams()reads the path params. A route component is re-created when its own params change.useSearchParams()reads and writes the query string:const [params, setParams] = useSearchParams(), thenparams().get("q")andsetParams({ q: "cajoa", page: null })(nullremoves a key).useLocation()returns{ pathname, search, hash }.- Serving the app from a sub-path? Use
<Router base="/my-app">(with Vite:base={import.meta.env.BASE_URL}). Keep writing app paths inLinkandnavigate, without the base. <NavLink>adds anactiveclass (activeClassto change it). Useendfor an exact match.<Navigate to="/login" />redirects.navigate(path, { replace, scroll })navigates from code. New pages scroll to the top, or to the#hashtarget.
Forms, styles and events
const [name, setName] = createSignal("");
const [agree, setAgree] = createSignal(false);
<form>
<input
placeholder="Name"
value={name}
onInput={(e) => setName(e.currentTarget.value)}
/>
<label>
<input
type="checkbox"
checked={agree}
onChange={(e) => setAgree(e.currentTarget.checked)}
/>
I agree
</label>
<p style={() => ({ color: agree() ? "green" : "gray", fontSize: "18px" })}>
Hello, {() => name() || "stranger"}!
</p>
</form>;Refs and SVG
function SearchBox() {
let input: HTMLInputElement | undefined;
return (
<div>
<input ref={(el) => (input = el)} />
<button onClick={() => input?.focus()}>Focus</button>
</div>
);
}value,checkedandselectedare set as DOM properties, so they stay in sync even after the user typed. Writevalueaftertype,minandmax.styleis a string or an object (keys in camelCase or kebab-case), and the values are strings:style={{ width: "100px" }}. Cajoa does not addpx, so a number would be ignored by the browser. Numbers are not converted topx, so write"12px". For a reactive style, pass a function:style={() => ({ color: color() })}. An object with signals inside, likestyle={{ color: color() }}, is read once.- Events are native DOM events.
onChangefires when a field loses focus, so useonInputfor every keystroke.onDoubleClicklistens todblclick. classNameandhtmlForwork. Inline string handlers likeonclick="..."are ignored.refis a function that receives the element once it is created. Its props and children are ready, but it is not in the page yet, so useonMountif you need to measure it right away. Signals read insiderefare not tracked.- SVG works:
<svg>,<path>,<circle>, gradients, filters and the other SVG tags are created in the SVG namespace. Write attributes asstroke-width="2"orstrokeWidth="2". - Event handlers are batched: several signal or store updates inside one handler cause a single update. This covers the part before the first
await; after that, wrap updates inbatch(() => ...).
import { render, Router, Route, Link, useParams } from "cajoa";
const User = () => <h1>User {useParams().id}</h1>;
render(
() => (
<div>
<nav>
<Link href="/">Home</Link> <Link href="/users/7">User 7</Link>
</nav>
<Router>
<Route path="/" component={() => <h1>Home</h1>} />
<Route path="/users/:id" component={User} />
<Route path="*" component={() => <h1>Not found</h1>} />
</Router>
</div>
),
document.getElementById("app")!,
);Routes are matched in order, so put path="*" last.
Error handling
import { ErrorBoundary } from "cajoa";
<ErrorBoundary
fallback={(error, reset) => (
<div>
<p>Something went wrong: {String(error)}</p>
<button onClick={reset}>Try again</button>
</div>
)}
>
<Dashboard />
</ErrorBoundary>;- It catches errors thrown while a component is created, inside effects and memos, and in
onMount. The nearest boundary wins. An error in a fallback goes to the next boundary up. - It does not catch errors in event handlers, timers or promises. Handle those yourself.
fallbackis an element or a function(error, reset). Callingreset()re-creates the children. UseonErrorto report the error (for example to a logging service).- With the Vite plugin, the children and an element
fallbackare created lazily, likeShow. Without it, pass functions:<ErrorBoundary fallback={...}>{() => <Dashboard />}</ErrorBoundary>. - Like
Show, it must sit inside an element, not be the root returned byrender(renderthrows a clear error). catchError(fn, handler)is the low-level building block.
Async data
import { createResource, createSignal } from "cajoa";
const [userId, setUserId] = createSignal(1);
const [user, { refetch, mutate }] = createResource(userId, (id) =>
fetchUser(id),
);
<p>{user.loading() ? "Loading…" : user()?.name}</p>;createResource(source, fetcher)callsfetcher(value)wheneversource()changes. The fetcher can return a value or a promise. With one argument,createResource(fetcher)loads once, and again onrefetch().- While
source()isfalse,nullorundefined, nothing is fetched anduser()isundefined. user()returns the data, and keeps returning the previous data while a reload is in progress.user.loading()anduser.error()are reactive.- If the request failed, reading
user()throws the error, so an<ErrorBoundary>can show a fallback. Useuser.error()to read it without throwing. - A response that arrives after a newer request has started, or after the owner is disposed, is ignored.
refetch()loads again with the same source.mutate(value)sets the data directly (for example, an optimistic update).- Inside a
<Suspense>, the first load shows the fallback.
Switch, Match, Dynamic and Portal
<Switch fallback={<p>Unknown</p>}>
<Match when={status() === "loading"}><Spinner /></Match>
<Match when={status() === "error"}><p>Something went wrong</p></Match>
</Switch>
<Dynamic is={view} title="Hello" />
<Portal>
<div class="modal">…</div>
</Portal>Switchrenders the firstMatchwhosewhenis truthy, otherwisefallback. The branches are created lazily, only when selected, and a branch is not re-created while it stays selected.Matchmust be a direct child ofSwitch.Dynamicrenders a tag name or a component that can change at runtime.isis a function that returns a tag name ("h2") or a component, for example a signal. To render one component directly, writeis={() => Page}. The other props are passed on. When you store a component in a signal, set it withsetView(() => Page):setView(Page)would callPageas an updater.Portalrenders its children somewhere else.mountis an element, and the default isdocument.body. The content is removed when the portal is disposed. DOM events bubble through the DOM tree, not the component tree.
Stores
import { createStore, For } from "cajoa";
const [state, setState] = createStore({
user: { name: "Ada", age: 30 },
todos: [{ id: 1, text: "Learn Cajoa", done: false }],
});
<p>{state.user.name}</p>
<For each={state.todos}>{(todo) => <li>{todo.text}</li>}</For>
setState("user", "name", "Grace"); // a path, then a value
setState("todos", 0, "done", true); // arrays work with indexes
setState("user", { age: 31 }); // an object is merged
setState("todos", (list) => [...list, newTodo]); // a function gets the previous value and replaces it
setState((draft) => { // or edit a draft
draft.user.name = "Linus";
draft.todos.push(newTodo);
});- Reads are tracked per property: something that reads
state.user.namedoes not update whenstate.user.agechanges. stateis read-only. Changing it directly (state.count = 1) throws, so you never lose reactivity by accident.- An object is merged (shallow) into the existing one. A function replaces the value with what it returns. Setting a key to
undefinedremoves it from an object. - Only plain objects and arrays are reactive. A
Date, aMapor a class instance is a value you replace as a whole. - The store takes ownership of the object you pass in. Items keep their identity, so
<For>reuses their DOM nodes. - One
setStatecall is one update.unwrap(state)gives the plain object behind the store (do not change it).
Server-side rendering
import { renderToString, renderToStringAsync } from "cajoa/server";
const html = renderToString(() => <App />, { url: "/users/42" });
const page = await renderToStringAsync(() => <App />, {
url: req.url,
onRedirect: (to, status) => res.redirect(status, to),
});- On the server,
isServer()istrue.onMountdoes not run (sowindowis safe there),refis not called, and event handlers are left out. <Router>uses theurlof the request, and every request has its own router state, so parallel requests are safe. Put components that useuseLocation,NavLinkand similar inside<Router>.<Navigate>callsonRedirectinstead of rendering.- Set
hydratable: trueto write hydration markers. This is experimental: there is nohydrate()yet, and the markers can change. renderToStringAsynchas atimeout(10 seconds by default) and throws when it is reached.- There is no hydration yet: in the browser,
render()replaces the content of the container.hydrate()is planned.
Page title and meta tags
import { Title, Meta } from "cajoa";
<Title>About | My site</Title>
<Meta name="description" content="What this site is about" />- In the browser,
<Title>setsdocument.titleand puts the old title back when it is removed.<Meta>adds or updates a<meta>tag and removes it again. Both accept a function for a live value. - On the server they fill an object that you pass in. Use
headToString(head)to put it in your HTML:
const head = {};
const html = await renderToStringAsync(() => <App />, { url: req.url, head });
const page = template
.replace("<!--head-->", headToString(head))
.replace("<!--app-->", html);TypeScript
JSX is typed. The editor knows every HTML and SVG element, its attributes, and what each event gives you:
<input
value={name()}
onInput={(e) => setName(e.currentTarget.value)} // e.currentTarget is an HTMLInputElement
/>- Every attribute accepts a value or a function that returns it (
class={() => ...}). Events,refandchildrendo not. styleis a string or an object, and the values are strings:style={{ width: "100px" }}. Cajoa does not addpx, so a number would be ignored by the browser.- ARIA booleans are strings:
aria-hidden="true". Use camelCase for multi-word attributes (tabIndex,readOnly,maxLength), andclassorclassName,fororhtmlFor. data-*attributes and custom elements (<my-widget>) are accepted.- Rare SVG elements (
feGaussianBlur,animate, ...) accept any attribute. - The types live in
@cajoa/dom;JSX,CSSPropertiesandTargetedEventare exported fromcajoa.
Lazy components and Suspense
import { lazy, Suspense } from "cajoa";
const Settings = lazy(() => import("./Settings"));
<Suspense fallback={<p>Loading…</p>}>
<Settings />
</Suspense>;lazy(loader)loads a component the first time it is rendered. The loader returns a module with a default export (or the component itself). Once loaded, it renders without delay.<Suspense>shows itsfallbackuntil everything inside it has finished its first load: lazy components andcreateResourcecalls. Then the content stays. A reload, or something that starts loading later, does not bring the fallback back.- The children are created right away (so loading can start) but stay hidden while the fallback shows.
- A failed load is thrown, so an
<ErrorBoundary>can catch it. - Like
Show,Suspensemust sit inside an element, not be the root returned byrender.
Dev mode
The Vite plugin turns on dev mode on the dev server only (it sets globalThis.__CAJOA_DEV__ = true). In dev mode, Cajoa warns when onCleanup or useContext is called outside a component, and when <For> finds duplicate keys. It also logs every error caught by an ErrorBoundary. Production builds log nothing. Without the plugin, set the flag yourself.
Packages
| Package | What it is |
| --------------- | ---------------------------------------------------------------------------------------------------- |
| cajoa | The package most apps install (core + DOM) |
| @cajoa/core | Renderer-agnostic core: signals, JSX runtime, Show, For, context, Renderer interface |
| @cajoa/dom | DOM renderer and router |
| create-cajoa | Project starter (npm create cajoa@latest) |
| @cajoa/server | Server-side rendering: renderToString and renderToStringAsync (also available as cajoa/server) |
API
Reactivity
| Function | What it does |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| createSignal(value, { equals?, name? }) | Returns [get, set]. Reading get() inside an effect subscribes to it. set accepts a value or an updater function. |
| createEffect(fn) | Runs fn now and again whenever a signal it read changes. |
| createMemo(fn) | Cached computed value. It is computed when it is read, and it only notifies when the result changes. |
| batch(fn) | Groups several set calls into one update. |
| untrack(fn) | Runs fn without subscribing to signals. |
| createSelector(source) | Returns isSelected(key). Only the old and the new key wake up when source changes, so picking a row in a big list stays cheap. |
Components
| Component | What it does |
| ---------------------- | --------------------------------------------------------------------------------------------------------- |
| <Show when fallback> | Conditional rendering. children and fallback are functions. |
| <For each key?> | List rendering. Reuses DOM nodes for unchanged items. The child is a function that returns a single node. |
| <>...</> (Fragment) | Groups siblings without a wrapper element. |
Rendering
| Function | What it does |
| ------------------------------ | ------------------------------------------------- |
| render(component, container) | Mounts a component. Returns a dispose function. |
| h(tag, props, ...children) | Low-level element creator. JSX compiles to this. |
Lifecycle
| Function | What it does |
| ---------------- | ----------------------------------------------------------------- |
| onMount(fn) | Runs fn after the component is rendered. |
| onCleanup(fn) | Runs fn before an effect re-runs or when its owner is disposed. |
| createRoot(fn) | Creates a top-level owner you can dispose manually. |
Errors
| Function | What it does |
| ----------------------------------- | --------------------------------------------------------------------------------------- |
| <ErrorBoundary fallback onError?> | Shows fallback (an element, or (error, reset) => …) when something below it throws. |
| catchError(fn, handler) | Runs fn. Errors it throws, now or later (effects, onMount), go to handler. |
Async data
| Function | What it does |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------- |
| createResource(source?, fetcher) | Loads data (sync or async) when source() changes. Returns [resource, { refetch, mutate }]. |
| resource() | The data (the previous data while reloading). Throws if the request failed. |
| resource.loading() / resource.error() | Reactive loading state and error. |
Control flow
| Component | What it does |
| ------------------------------------ | ---------------------------------------------------------------------------- |
| <Switch fallback> / <Match when> | Renders the first matching branch, or the fallback. |
| <Dynamic is> | Renders a tag or component that can change. is is a function returning it. |
| <Portal mount?> | Renders its children into another element (default: document.body). |
| lazy(loader) | A component loaded on first use (() => import("./Page")). |
| <Suspense fallback> | Shows the fallback until the first load of everything inside has finished. |
Stores
| Function | What it does |
| ---------------------------- | --------------------------------------------------------------------------- |
| createStore(initial) | Returns [state, setState]. state is read-only and tracked per property. |
| setState(...path, value) | Sets a value (objects are merged, functions replace). |
| setState(draft => { ... }) | Edits a draft, as one update. |
| unwrap(state) | The plain object behind the store. |
Context
| Function | What it does |
| ------------------------- | ------------------------------------------------------ |
| createContext(default) | Creates a context with a default value. |
| provide(ctx, value, fn) | Runs fn; everything created inside can read value. |
| useContext(ctx) | Reads the nearest provided value (or the default). |
Router
| Function | What it does |
| ------------------------------------- | ------------------------------------------------------------------------------------------- |
| <Router base?> | Renders the route that matches the current path. base is for apps served from a sub-path. |
| <Route path component> | Maps a path (/users/:id, *, docs/*) to a component. Nest routes to build layouts. |
| <Outlet /> | Renders the matching child route inside a layout. |
| <Link href> | An <a> that navigates without reloading the page. |
| <NavLink href end? activeClass?> | A Link that gets an active class on the current page. |
| <Navigate to replace?> | Redirects when it is rendered. |
| navigate(to, { replace?, scroll? }) | Navigates programmatically. |
| useParams() | Reads the route params ({ id: "7" }). |
| useLocation() | Returns a getter for { pathname, search, hash }. |
| useSearchParams() | Returns [params, setParams] for the query string. |
| usePath() | Returns a getter for the current pathname. |
Renderer
| Function | What it does |
| ----------------------- | ------------------------------------------------------------------ |
| setRenderer(renderer) | Swaps the renderer (default: DOM). Used to target other platforms. |
| getRenderer() | Returns the current renderer. |
| domRenderer | The built-in DOM renderer. |
Server
| Function | What it does |
| ------------------------------------ | ------------------------------------------------------------------------ |
| renderToString(app, options?) | Renders to an HTML string (cajoa/server). Does not wait for resources. |
| renderToStringAsync(app, options?) | Same, but waits for createResource and lazy. |
| isServer() | true while rendering on the server. |
Good to know
Foruses the item itself as the key by default. If you passkey, items with the same key are not re-rendered when their data changes, so keys must be unique and the item should hold its own signals if it needs to update.- Inside
<For>, return a single element (not a fragment), becauseForreuses that node. useContextanduseParamsonly work while a component is being created (or insideonMount/ effects). Read them at the top of your component and use the value later in handlers.- Updating an object in place will not trigger effects. Set a new object instead.
- Updates are glitch-free: when several signals or memos change together, each effect runs once, after everything it depends on is up to date, so it never sees a half-updated state. An update started inside an effect is queued and runs after that effect finishes. If an effect keeps changing a signal that it also reads, Cajoa stops with an error instead of looping forever.
- Reordering a keyed list (
<For>) only moves the nodes that have to move: swapping two rows moves two nodes, however long the list is.
Known limitations
- No hydration yet: in the browser,
render()replaces the content of the container, even if it was rendered on the server. - Single-page apps need a server fallback to
index.htmlfor unknown paths. <select value>works only when the<option>elements are direct children of the<select>.- There is no
innerHTMLprop. To insert raw HTML, set it yourself in aref, and only from content you trust. - The router reads its routes once, so a route list that changes later is not supported.
- Types in the core use a loose
HostNodetype for now. - Inside
<svg>, the tags<a>,<title>,<style>and<script>are created as HTML elements (they share their names with HTML tags). - The compiler works on
.tsxand.jsxfiles in Vite projects.ShowandForare recognised only when imported fromcajoaor@cajoa/core. ErrorBoundarydoes not catch errors thrown in event handlers, timers or promises.Suspenseshows its fallback for the first load only. Useresource.loading()for later reloads.
Roadmap
Shipped
- [x] Signals, effects, memo, batch
- [x] DOM bindings,
Show, keyedFor, Fragments - [x] Lifecycle, context
- [x] JSX support and the Vite plugin (reactive JSX)
- [x] Router
- [x] Renderer abstraction, split into packages
- [x] Automated releases
- [x] Form, style and event handling in the DOM renderer
- [x] SVG and
refsupport - [x] Router v2: nested routes, base path, query and hash
- [x] Real-browser tests
- [x] Error boundaries and dev-mode warnings
- [x] Async data (
createResource) - [x]
Portal,Dynamic,Switch/Match, lazy components - [x] Stores (
createStore), automatic batching, glitch-free updates - [x] Glitch-free updates (memos that always agree with each other)
- [x] Benchmarks and bundle-size budget
- [x] Server-side rendering (
@cajoa/server) - [x] Typed JSX (HTML attributes and events)
- [x] Docs site built with Cajoa
- [x] Page title and meta tags (, )
(next)
Next
- [ ] Developer tools: see which signals and effects run on each update
- [ ] Testing utilities
- [ ] Hydration
Development
npm install
npm test # run tests
npm run dev # open the playground
npm run verify # typecheck, tests, build, sync and package checks
npm run e2e:install # one time: download the browsers
npm run test:e2e # run the browser testsSee CONTRIBUTING.md for the release flow.
