State and logic that lives outside the UI tree.
Olas pulls everything that isn't rendering — fetching, mutations, forms, business rules, cross-screen coordination — into a parallel tree of typed controllers. Your components stay thin and your logic becomes plain TypeScript you can read top to bottom and test without spinning up a renderer.
import { defineController, signal } from '@kontsedal/olas-core'
export const counter = defineController(() => {
const count = signal(0)
return {
count,
increment: () => count.update((n) => n + 1),
}
})That's a controller. It's a function that returns an object. Components subscribe to it, call methods on it, and never own its lifetime.
- Why
- Install
- A five-minute tour
- Common recipes
- How it scales
- Packages
- Examples
- How it compares
- Learn more
Most apps end up in one of three places:
- State inside components. Easy at first. At scale it sprawls — the same data fetched twice, side effects tangled with renders, tests boot a renderer to verify a single rule.
- One big store. Scalable but blunt. Everything is global, ownership is implicit, lifetimes are forever.
- Hooks at the top of pages. Hides ownership. Two pages mounting the same hook re-fetch instead of sharing. Lifecycle is "whatever React does."
Olas takes a different shape. There's a controller tree that mirrors your app's features (not your component tree). Each controller owns its slice — its signals, its queries, its mutations — and is disposed explicitly when the feature unmounts. Components are read-only renderers that subscribe to controllers via small adapter hooks.
The practical wins:
- Logic without renderers. A controller is a function. Tests pass in fake
deps, call methods, and assert against signals. Norender(<App />), no Testing Library, no fake timers chasing effect flushes. - Explicit lifetimes. Every field, query, mutation, and child controller dies with its parent. No "what owns this subscription?" mystery.
- Shared queries by default. Two controllers subscribing to the same query share one fetch and one cache entry. The same primitive scales from "one widget" to "every screen on the dashboard."
- Framework-agnostic core.
@kontsedal/olas-corenever imports React. The React adapter is ~230 lines on top ofuseSyncExternalStore. The same controllers can drive Vue, Svelte, or vanilla DOM with a small adapter.
pnpm add @kontsedal/olas-core @kontsedal/olas-react @preact/signals-core react
# optional add-ons (each independent — pick what you use)
pnpm add @kontsedal/olas-persist @kontsedal/olas-zod @kontsedal/olas-devtools zod
pnpm add @kontsedal/olas-cross-tab @kontsedal/olas-entities @kontsedal/olas-realtime
pnpm add @kontsedal/olas-mutation-queue @kontsedal/olas-router@preact/signals-core is a peer dep on @kontsedal/olas-core — the library does not bundle it.
Six concepts, each smaller than the last. By the end you can read any Olas codebase.
import { signal, computed, effect } from '@kontsedal/olas-core'
const count = signal(0)
const double = computed(() => count.value * 2)
count.set(5)
console.log(double.value) // 10
effect(() => {
console.log('count is', count.value)
})
count.update((n) => n + 1) // logs "count is 6"A signal is a typed cell with .value (read) and .set(...) / .update(fn) (write). computed(...) derives a read-only signal that recomputes when its dependencies change. effect(...) runs side effects, re-running when its dependencies change.
Olas wraps @preact/signals-core behind these types. It's small (~1 kB), fast, and glitch-free.
A controller is a function from ctx to an API object.
import { defineController, signal } from '@kontsedal/olas-core'
const counter = defineController((ctx) => {
const count = signal(0)
ctx.effect(() => {
document.title = `Count: ${count.value}`
})
return {
count,
increment: () => count.update((n) => n + 1),
reset: () => count.set(0),
}
})ctx is a factory bound to this controller's lifetime. Anything created through ctx — effects, child controllers, fields, queries, mutations, emitters — is disposed when the controller is disposed.
Mount the controller as a root once, near your app entry point.
import { createRoot } from '@kontsedal/olas-core'
const root = createRoot(counter, { deps: {} })
root.increment()
console.log(root.count.value) // 1
root.dispose() // tears down the effect, signals, everythingdeps is required (more on this in Dependency injection). For trivial apps, {} is fine.
// main.tsx
import { createRoot as createReactRoot } from 'react-dom/client'
import { createRoot as createOlasRoot } from '@kontsedal/olas-core'
import { OlasProvider } from '@kontsedal/olas-react'
import { counter } from './counter'
import { App } from './App'
const root = createOlasRoot(counter, { deps: {} })
createReactRoot(document.getElementById('root')!).render(
<OlasProvider root={root}>
<App />
</OlasProvider>
)Export the api type alongside the controller so components can reach for it without poking at framework types:
// counter.ts (cont.)
import type { ReadSignal } from '@kontsedal/olas-core'
export type CounterApi = {
count: ReadSignal<number>
increment: () => void
reset: () => void
}// App.tsx
import { use, useRoot } from '@kontsedal/olas-react'
import type { CounterApi } from './counter'
export function App() {
const api = useRoot<CounterApi>()
const count = use(api.count)
return (
<div>
<p>{count}</p>
<button onClick={api.increment}>+</button>
<button onClick={api.reset}>reset</button>
</div>
)
}use(signal) subscribes a component to one signal. useRoot<Api>() resolves the controller's public API from the provider. The component is a thin renderer — all behavior lives on api.
For data that comes from the network and might be shared across screens, define a query at module scope:
import { defineQuery } from '@kontsedal/olas-core'
export const userQuery = defineQuery({
key: (id: string) => [id],
fetcher: async ({ signal }, id) => {
const res = await fetch(`/api/users/${id}`, { signal })
if (!res.ok) throw new Error(res.statusText)
return res.json() as Promise<{ id: string; name: string; email: string }>
},
staleTime: 30_000,
})Subscribe to it from a controller. ctx.use returns an AsyncState<T> — eight signals you can read individually.
import { defineController } from '@kontsedal/olas-core'
export const userProfile = defineController((ctx, props: { id: string }) => {
const user = ctx.use(userQuery, () => [props.id])
return { user }
})In a component, useQuery collapses those eight signals into one render:
import { useQuery, useRoot } from '@kontsedal/olas-react'
function UserCard() {
const api = useRoot<UserProfileApi>()
const { data, isLoading, error } = useQuery(api.user)
if (isLoading) return <Spinner />
if (error) return <ErrorBox error={error} />
return <h1>{data?.name}</h1>
}Two controllers subscribing to the same userQuery with the same id share one fetch and one cache entry. When the last subscriber disposes, the entry is collected after gcTime (5 min default).
import { defineController } from '@kontsedal/olas-core'
export const userProfile = defineController((ctx, props: { id: string }) => {
const user = ctx.use(userQuery, () => [props.id])
const updateName = ctx.mutation<string, void>({
mutate: async (newName, signal) => {
const res = await fetch(`/api/users/${props.id}`, {
method: 'PATCH',
body: JSON.stringify({ name: newName }),
signal,
})
if (!res.ok) throw new Error('save failed')
},
onMutate: (newName) =>
userQuery.setData(props.id, (prev) => {
if (!prev) throw new Error('updateName before user loaded')
return { ...prev, name: newName }
}),
onError: (_err, _vars, snapshot) => {
snapshot?.rollback()
},
})
return { user, updateName }
})onMutate runs an optimistic update before the network call and returns a snapshot. If the call fails, onError calls snapshot.rollback() and the UI reverts.
Three concurrency modes (parallel is default):
parallel— every.run(...)is independent.latest-wins— a new.run(...)aborts the in-flight one.serial— runs queue up and execute one at a time.
import { defineController, required, minLength, email } from '@kontsedal/olas-core'
export const signupForm = defineController((ctx) => {
const form = ctx.form({
name: ctx.field('', [required('Name is required')]),
email: ctx.field('', [required(), email()]),
password: ctx.field('', [minLength(8, 'Min 8 characters')]),
})
return {
form,
submit: ctx.mutation<void, void>({
mutate: async () => {
form.markAllTouched()
if (!(await form.validate())) throw new Error('invalid')
const v = form.value.value
// ...send v.name, v.email, v.password to the server
},
}),
}
})A Form aggregates fields (and nested forms, and FieldArrays) into a single typed value signal plus isValid, isDirty, touched, isValidating. Components subscribe one field at a time with useField:
import { useField } from '@kontsedal/olas-react'
function NameInput({ field }: { field: Field<string> }) {
const f = useField(field)
return (
<label>
<span>Name</span>
<input value={f.value} onChange={(e) => f.set(e.target.value)} onBlur={f.markTouched} />
{f.touched && f.errors[0] && <em>{f.errors[0]}</em>}
</label>
)
}For schema-driven forms, @kontsedal/olas-zod walks a z.object(...) tree and emits the matching Form / Field / FieldArray structure with validators auto-attached:
import { z } from 'zod'
import { formFromZod } from '@kontsedal/olas-zod'
const Schema = z.object({
name: z.string().min(2),
age: z.number().min(0),
})
const form = formFromZod(ctx, Schema)
// form.value: ReadSignal<{ name: string; age: number }>That's the whole tour. Everything else in Olas is variations on these six pieces.
deps is a typed object passed to createRoot and available everywhere as ctx.deps. Use it for anything the app talks to externally (api clients, routers, analytics, the current time).
// deps.ts
export interface AppDeps {
api: { getUser(id: string): Promise<User> }
router: { navigate(path: string): void }
}
declare module '@kontsedal/olas-core' {
interface AmbientDeps extends AppDeps {}
}const root = createRoot(appController, {
deps: {
api: realApiClient,
router: realRouter,
},
})In tests, pass in fakes — no mocking framework needed.
For "controller A fires an event, controller B reacts," use an emitter on ctx.deps (or a defineScope for shared in-tree state).
const activity = defineController((ctx) => {
const log = signal<string[]>([])
ctx.on(ctx.deps.activity, (msg) => log.update((l) => [...l, msg]))
return { log }
})Pattern shown above in §5. The key rule: onMutate returns a snapshot; onError calls snapshot.rollback(). Rollback is automatic only on abort (e.g., a latest-wins mutation superseded). For normal errors, do it explicitly.
import { signal } from '@kontsedal/olas-core'
import { usePersisted } from '@kontsedal/olas-persist'
const theme = signal<'light' | 'dark'>('light')
usePersisted(ctx, 'theme', theme)usePersisted reads the saved value on construction and writes through on every change. Works for any signal-shaped source (signal, field, or anything exposing .value / .set / .subscribe). Cross-tab sync via crossTab: true.
// server
const root = createRoot(app, { deps: serverDeps })
renderToString(<OlasProvider root={root}><App /></OlasProvider>)
await root.waitForIdle()
const state = root.dehydrate()
// inline `state` into the HTML response// client
const root = createRoot(app, { deps: clientDeps, hydrate: state })The cache survives the boundary. Queries already in state don't refetch on the client.
import { DevtoolsLauncher } from '@kontsedal/olas-devtools'
<OlasProvider root={root}>
<App />
<DevtoolsLauncher root={root} />
</OlasProvider>A floating button opens a panel with the controller tree, cache timeline, and mutation log. Gate behind import.meta.env.DEV for prod builds.
Because controllers are pure TypeScript with no renderer involvement, AI coding assistants (Claude Code, Cursor, Copilot Workspace) can work on business logic in isolation:
- Hand the assistant
someController.tsplus its test file. It iterates withpnpm vitest run packages/foo/tests/someController.test.tsagainstcreateTestController— no jsdom, no Testing Library, no fake DOM tree. - For UI work, export
type FooApifrom the controller and hand it plus the React file to the assistant. It mapsapi.foo.run()to a button without needing to understand the optimistic-rollback logic happening behind it.
Foundation models default to React/Redux idioms — without rules pinning Olas's invariants ("UI doesn't fetch", "controllers stay synchronous", "tests don't render"), output drifts. The repo ships:
.cursorrules— short rules file that Cursor (and other rule-aware assistants) injects per prompt.CLAUDE.md— long-form operating instructions for AI assistants working on the framework itself (wiki schema, BACKLOG protocol, codebase-specific gotchas).
For projects building with Olas, copy .cursorrules into your repo and trim to your conventions.
| Concern | What changes as the app grows |
|---|---|
| Many features | Add more controllers; compose them via ctx.child(...). The tree mirrors features, not screens. |
| Shared data | defineQuery at module scope. Multiple subscribers share one fetch automatically. |
| Many roots / tests in parallel | Each createRoot(...) is isolated; query entries live per-root. Tests run in parallel without leaking state. |
| User-driven sub-trees | ctx.attach(...) gives you a child controller plus a dispose() handle — close the panel, the sub-tree (and its subscriptions) goes with it. |
| Cross-tree config | defineScope<T>() + ctx.provide(scope, value) / ctx.inject(scope) — typed cross-tree data without prop drilling. |
For more depth, every concept above maps to a section in SPEC.md.
| Package | What it gives you |
|---|---|
@kontsedal/olas-core |
Everything: signals, controllers, queries, mutations, forms, scopes, SSR, devtools event bus. |
@kontsedal/olas-react |
React adapter — OlasProvider, useRoot, use, useQuery, useField, KeepAlive, useSuspendOnHidden. |
@kontsedal/olas-persist |
usePersisted + localStorage adapter. |
@kontsedal/olas-zod |
zodValidator(schema) + formFromZod(ctx, schema). |
@kontsedal/olas-devtools |
In-app <DevtoolsPanel> + floating launcher consuming root.__debug. |
@kontsedal/olas-cross-tab |
BroadcastChannel-backed cross-tab cache sync as a QueryClientPlugin. |
@kontsedal/olas-entities |
defineEntity + auto-walk + reverse-index backprop for normalized entities across queries. |
@kontsedal/olas-realtime |
useRealtimePatcher + useLiveStream over a consumer-supplied RealtimeService. |
@kontsedal/olas-mutation-queue |
Durable, replay-safe mutation queue. Persists defineMutation({ persist: true }) runs to a StorageAdapter; replays pending entries on init after reload / crash. |
@kontsedal/olas-router |
Generic router bridge — createRouterAdapter() plus RouteParamsScope / RouteSearchScope / RoutePathnameScope. Works with TanStack Router or React Router v6. |
Outstanding work — additional storage adapters, Vue/Svelte adapters, browser-extension devtools — is tracked in BACKLOG.md.
Four runnable example apps live in examples/. Each is a real (small) application — not a snippet — with its own dev server and unit tests.
| App | Stack | What it shows |
|---|---|---|
stock-ticker |
Vanilla TS — no UI framework | Signals, computed, effect, emitter, throttled/debounced, defineQuery + refetchInterval, usePersisted watchlist, SVG sparklines. |
kanban |
React + Devtools | All three mutation concurrency modes, optimistic snapshot rollback, formFromZod + FieldArray, defineScope, error-toast retry, activity feed, mounted <DevtoolsLauncher>. |
reader-ssr |
React + SSR | waitForIdle → dehydrate → hydrate round-trip, paginated defineQuery, useSuspendOnHidden, usePersisted × 3 (bookmarks, theme, reading progress), onError root option. |
virtualized-table |
React | SPEC §11's "rows are data, not controllers" pattern — 50k-row virtualized table backed by Map<id, Signal<Row>>, per-row mutation, devtools-friendly. |
pnpm install
pnpm --filter @kontsedal/olas-example-kanban dev # or stock-ticker, reader-ssr, virtualized-table
pnpm --filter @kontsedal/olas-example-kanban testEvery business-logic surface in these examples is covered by a controller test that uses createTestController, fakeField, and fakeAsyncState from @kontsedal/olas-core/testing — no rendered components.
These are honest, terse sketches. None of them are reasons to leave a tool you're happy with.
vs. Redux Toolkit / Zustand. A store is one big object. A controller tree is many small objects, each owning its slice and lifetime. Olas has no reducers, no slices, no selectors — you read signals directly, you call methods directly. The "selector" problem (re-render on unrelated changes) doesn't exist because subscriptions are per-signal.
vs. TanStack Query + Zustand. TanStack handles the network; Zustand handles the rest; gluing them together is application code. Olas is one model: queries, mutations, and ephemeral state all live in the same controller, with the same lifetime, in the same place.
vs. MobX. Both are signal-graph-based. MobX is class-oriented with decorators; Olas is function-oriented with a ctx factory and explicit lifetime ownership. Tests in Olas don't need MobX-runtime configuration.
vs. Effector / XState. Effector is signal-graph-based at a finer grain (effects, stores, events as primitives). XState is state-machine-first. Olas sits between: signal-graph for data, but with controllers as the unit of ownership.
API.md— complete API reference: every export, signature, signature-typechecked example, gotchas. The "leave no questions" doc.SPEC.md— authoritative design. Read top to bottom or jump by§N.Msection.RECIPES.md— reusable user composables (useDebounced,usePagination,useSubmit,useInlineEdit,useTail,useRealtimePatcher).MIGRATING.md— coming from TanStack Query or Redux Toolkit..wiki/index.md— codebase wiki: per-module pages, design decisions, recorded pitfalls..wiki/overview.md— one-page architecture.BACKLOG.md— proposed extensions, post-v1 packages, deferred ideas.CLAUDE.md— orientation for AI assistants working in this repo..cursorrules— short rules file for AI assistants writing Olas code in your projects; copy into your repo.
pnpm install # link workspace + install
pnpm typecheck # tsc --noEmit per package
pnpm lint # biome check .
pnpm test # vitest run (all packages)
pnpm build # tsdown per package → dist/{mjs,cjs,d.mts,d.cts}
pnpm wiki:lint # check .wiki/ for broken refsCI = install → typecheck → lint → test → build. 621 tests across 55 files (including a cross-package packages/integration suite), all green.
MIT.