State and logic that lives outside the UI tree.
Controllers, queries, mutations and forms with explicit lifetimes, on signals.
For React, Preact, Vue and Svelte.
Documentation · Getting started · API reference · Examples · Changelog
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: a function that returns an object. Components subscribe to it, call its methods, and do not own its lifetime.
Because it's a function, here's the entire test — no renderer, no jsdom, no Testing Library:
import { createTestController } from '@kontsedal/olas-core/testing'
import { expect, test } from 'vitest'
test('counter increments', () => {
const { api } = createTestController(counter, { deps: {} })
api.increment()
expect(api.count.peek()).toBe(1)
})Scale that controller up — shared queries, optimistic mutations with rollback, whole forms — and the test still never boots a DOM. That's the pitch in one line: your app's logic becomes plain TypeScript you can read top to bottom and test like any other function.
- Why
- Install
- A five-minute tour
- Common patterns
- Tooling
- Working with AI assistants
- How it scales
- Packages
- Examples
- How it compares
- Learn more
- Commands
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.
graph LR
subgraph CT["Component tree - thin renderers"]
direction TB
App --> ProfilePage
App --> Toolbar
end
subgraph OT["Controller tree - owns state and logic"]
direction TB
root --> userProfile
root --> session
userProfile --> data["queries / mutations / forms"]
end
ProfilePage -.->|"useValue()"| userProfile
Toolbar -.->|"useValue()"| session
Two trees, one arrow between them: components reach into the controller tree to read a signal, and that's the entire coupling. The logic doesn't know a component exists.
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-coredeclares no UI-framework dependency and does not import one. The React, Vue and Svelte adapters are thin layers: React's sits onuseSyncExternalStore, Vue's turns a signal into a read-only ref, and in Svelte a signal already is a store. Preact runs the React adapter throughpreact/compat, and plain DOM code subscribes to signals directly.
pnpm add @kontsedal/olas-core @kontsedal/olas-react @preact/signals-core react
# Vue or Svelte instead of React:
pnpm add @kontsedal/olas-core @kontsedal/olas-vue @preact/signals-core vue
pnpm add @kontsedal/olas-core @kontsedal/olas-svelte @preact/signals-core svelte
# 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
# lint rules for the conventions below
pnpm add -D @kontsedal/olas-eslint-plugin eslint@preact/signals-core is a peer dep on @kontsedal/olas-core — the library does not bundle it.
Every package ships ES modules only and needs Node 20.19 or later, where require() of an ES module works.
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. Read it with .value; write with .set(...) or .update(fn). 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.
// counter.ts
import { defineController, signal } from '@kontsedal/olas-core'
export 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 bound to this controller's lifetime. Anything created through ctx or the create* functions that take it — 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'
import { counter } from './counter'
const root = createRoot(counter, { deps: {} })
root.api.increment()
console.log(root.api.count.value) // 1
root.dispose() // tears down the effect, signals, everythingcreateRoot returns a handle. The controller's api is on root.api, and the root's own controls sit beside it: dispose, suspend, resume, dehydrate, hydrate, waitForIdle, bindQuery, inject and debug.
deps is required (more on this in Dependency injection). An app with no services passes {}. Once the app declares its services on AmbientDeps, createRoot requires them.
// 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: {} })
// Register the root's type once, so `useRoot()` needs no type argument.
declare module '@kontsedal/olas-react' {
interface Register {
root: typeof root
}
}
createReactRoot(document.getElementById('root')!).render(
<OlasProvider root={root}>
<App />
</OlasProvider>
)// App.tsx
import { useRoot, useValue } from '@kontsedal/olas-react'
export function App() {
const api = useRoot()
const count = useValue(api.count)
return (
<div>
<p>{count}</p>
<button onClick={api.increment}>+</button>
<button onClick={api.reset}>reset</button>
</div>
)
}useValue(signal) subscribes a component to one signal. useRoot() resolves the root's api from the provider, typed through the Register augmentation in main.tsx. Without one it returns unknown, and useRoot<Api>() names the type per call, as §4 does. The component is a thin renderer — all behavior lives on api.
Vue and Svelte read the same controller. @kontsedal/olas-vue installs the root with app.use(olasPlugin(root)) and returns refs, and @kontsedal/olas-svelte puts it in context with setRoot(root). See Packages.
For data that comes from the network and might be shared across screens, define a query at module scope:
// queries.ts
import { defineQuery } from '@kontsedal/olas-core'
export const userQuery = defineQuery({
id: 'users/detail', // required, unique, and the same in the server and client bundles
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,
})The id names the query everywhere a query has to be found by name: SSR payloads, plugins and devtools. Write it by hand, because a derived name changes under minification.
Subscribe to it from a controller. createQuery returns an AsyncState<T>: ten signals you can read one at a time, such as data, error, isLoading and isFetching, plus refetch, cancel, reset and firstValue.
// userProfile.ts
import { createQuery, type CtrlApi, defineController } from '@kontsedal/olas-core'
import { userQuery } from './queries'
export const userProfile = defineController((ctx, props: { id: string }) => {
const user = createQuery(ctx, userQuery, () => [props.id])
return { user }
})
export type UserProfileApi = CtrlApi<typeof userProfile>A root whose controllers create a query or a mutation needs a query engine: createRoot(app, { deps, queries: queryEngine() }). Without one, createQuery throws an error that names the fix. A root with no queries leaves the engine out, and its bundle leaves out the query cache.
In a component, useQuery reads the whole state in one hook, and re-renders only when a field the component read changes:
import { useQuery, useRoot } from '@kontsedal/olas-react'
import { ErrorBox, Spinner } from './ui'
import type { UserProfileApi } from './userProfile'
export 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>
}useSuspenseQuery(api.user) is the Suspense form: it suspends until the first value lands, and its data is never undefined.
Two controllers subscribing to the same userQuery with the same key share one fetch and one cache entry. When the last subscriber disposes, the entry is collected after gcTime, which defaults to five minutes.
graph TD
P["ProfilePage controller<br/>uses userQuery('u1')"] --> E
S["Sidebar controller<br/>uses userQuery('u1')"] --> E
E["one cache entry for key u1"] --> F["one fetch in flight"]
E --> G["gc'd after the last<br/>subscriber leaves + gcTime"]
You do not wire this up. Subscribing is the sharing. The same primitive scales from one widget to every screen on a dashboard.
import { bindQuery, createMutation, createQuery, defineController } from '@kontsedal/olas-core'
import { userQuery } from './queries'
export const userProfile = defineController((ctx, props: { id: string }) => {
const user = createQuery(ctx, userQuery, () => [props.id])
const users = bindQuery(ctx, userQuery)
const updateName = createMutation<string, void>(ctx, {
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) => {
users.cancel(props.id) // stop an in-flight refetch from clobbering the optimistic write
return users.setData(props.id, (prev) => {
if (!prev) throw new Error('updateName before user loaded')
return { ...prev, name: newName }
})
},
})
return { user, updateName }
})onMutate runs an optimistic update before the network call and returns a snapshot. It first calls users.cancel(...), where users is the root-scoped handle from bindQuery(ctx, userQuery), so an outgoing refetch's stale response cannot land on top of the optimistic value. If the call fails, the mutation rolls the snapshot back after onError runs, and the UI reverts. On success it finalizes the snapshot. Rollback restores server truth when a fetch succeeded in between; see SPEC §6.4.
mutate receives the variables and a context with the run's signal and the controller's deps.
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 {
createField,
createForm,
createMutation,
defineController,
email,
minLength,
required,
} from '@kontsedal/olas-core'
export const signupForm = defineController((ctx) => {
const form = createForm(ctx, {
name: createField(ctx, '', { validators: [required('Name is required')] }),
email: createField(ctx, '', { validators: [required(), email()] }),
password: createField(ctx, '', { validators: [minLength(8, 'Min 8 characters')] }),
})
return {
form,
submit: createMutation<void, void>(ctx, {
mutate: async () => {
form.markAllTouched()
if (!(await form.validate())) throw new Error('invalid')
const v = form.value
// ...send v.name, v.email, v.password to the server
},
}),
}
})A Form aggregates fields (and nested forms, and FieldArrays) and is itself a ReadSignal of their typed value, like a Field. It adds isValid, isDirty, touched and isValidating. form.submit(handler) is the shortcut when you need no mutation state: it validates first and resolves a SubmitResult you switch on. Components subscribe one field at a time with useField:
import type { Field } from '@kontsedal/olas-core'
import { useField } from '@kontsedal/olas-react'
export 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 and FieldArray structure with validators auto-attached:
import { z } from 'zod'
import { createZodForm } from '@kontsedal/olas-zod'
const Schema = z.object({
name: z.string().min(2),
age: z.number().min(0),
})
const form = createZodForm(ctx, Schema)
// form is a ReadSignal<{ name: string; age: number }>That's the whole tour. Everything else in Olas is variations on these six pieces.
The everyday wiring. For composable custom hooks — debounced writes, pagination, inline edit, realtime patching, router integration — see RECIPES.md.
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
import type { Emitter } from '@kontsedal/olas-core'
export type User = { id: string; name: string }
export interface AppDeps {
api: { getUser(id: string): Promise<User> }
router: { navigate(path: string): void }
activity: Emitter<string>
}
declare module '@kontsedal/olas-core' {
interface AmbientDeps extends AppDeps {}
}import { createEmitter, createRoot, queryEngine } from '@kontsedal/olas-core'
import { appController } from './app.controller'
import { realApiClient, realRouter } from './services'
const root = createRoot(appController, {
// App-wide query policy, declared once. A per-query `defineQuery` field
// always overrides it. Built-ins are staleTime: 0, retry: 0.
queries: queryEngine({ defaults: { staleTime: 5 * 60_000, retry: 1 } }),
deps: {
api: realApiClient,
router: realRouter,
activity: createEmitter<string>(),
},
})createRoot checks deps against AmbientDeps, so a root that leaves out router does not compile. In tests, pass in fakes — no mocking framework needed. createTestController does not check, so a test passes only the fakes its controller reads.
For "controller A fires an event, controller B reacts," use an emitter on ctx.deps (or a defineScope for shared in-tree state).
import { defineController, signal } from '@kontsedal/olas-core'
export 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 calls cancel on the query, then returns the snapshot setData gave it. The mutation settles the snapshot: it rolls back on an error or an abort (a superseded latest-wins run, a dispose) and finalizes on success. For a patch that is already true, such as a server push, use write, which leaves no snapshot to settle.
import { signal } from '@kontsedal/olas-core'
import { createPersisted } from '@kontsedal/olas-persist'
const theme = signal<'light' | 'dark'>('light')
createPersisted(ctx, 'theme', theme)createPersisted 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 and .subscribe). Cross-tab sync via crossTab: true. persistQueryCachePlugin from the same package persists the query cache instead.
// server.tsx
import { createRoot, queryEngine, serializeForScript } from '@kontsedal/olas-core'
import { OlasProvider } from '@kontsedal/olas-react'
import { renderToString } from 'react-dom/server'
import { App } from './App'
import { appController } from './app.controller'
import { serverDeps } from './deps.server'
export async function render(): Promise<string> {
const root = createRoot(appController, { queries: queryEngine(), deps: serverDeps })
await root.waitForIdle() // the controllers' queries started inside createRoot
const html = renderToString(
<OlasProvider root={root}>
<App />
</OlasProvider>,
)
const state = serializeForScript(root.dehydrate())
root.dispose()
return `<div id="root">${html}</div><script>window.__OLAS_STATE__ = ${state}</script>`
}// client.ts
import { createRoot, type DehydratedState, queryEngine } from '@kontsedal/olas-core'
import { appController } from './app.controller'
import { clientDeps } from './deps.client'
const state = (window as { __OLAS_STATE__?: DehydratedState }).__OLAS_STATE__
const root = createRoot(appController, {
queries: queryEngine(), // the engine owns the cache that `hydrate` seeds
deps: clientDeps,
hydrate: state,
})dehydrate() serializes every successful entry of every query and infinite query, keyed by the query's id. Hydrated queries respect staleTime; fresh entries skip the initial refetch. The client root needs queries: queryEngine(): a root without an engine has no cache, so it discards the payload, and development builds warn. serializeForScript escapes the payload for an inline <script>, so data containing </script> cannot end the tag. For streaming SSR, @kontsedal/olas-react ships createStreamingHydrator, which takes a CSP nonce.
Use bindQuery(ctx, query) inside controllers or root.bindQuery(query) outside them for imperative cache operations. The returned handle targets one root and can prefetch before any subscription exists. Unbound query methods throw (or reject their promise) when multiple roots have touched the query. See the migration notes.
import { DevtoolsLauncher } from '@kontsedal/olas-devtools'
import { OlasProvider } from '@kontsedal/olas-react'
import { App } from './App'
import { root } from './root'
export const tree = (
<OlasProvider root={root}>
<App />
{import.meta.env.DEV && <DevtoolsLauncher root={root} />}
</OlasProvider>
)A floating button opens a panel over root.debug: the controller tree, a timeline of cache and mutation events, the cache, an inspector, the mutation log and the fields. Press / to search it. Events a plugin sends through host.debug get their own lane on the timeline. Gate the launcher behind import.meta.env.DEV so production builds leave it out.
@kontsedal/olas-eslint-plugin checks the conventions the types cannot see. Its eight rules catch a React hook inside a controller factory, a defineQuery or defineMutation built inside a function, and an async factory. They also catch an optimistic setData with no cancel before it, a snapshot onMutate does not return, and @kontsedal/olas-core/testing imported outside a test. Two opt-in rules catch fetch inside a component, and a fetcher or mutate that does not pass its signal on.
// eslint.config.js
import olas from '@kontsedal/olas-eslint-plugin'
import tseslint from 'typescript-eslint'
export default [...tseslint.configs.recommended, olas.configs.recommended]recommended turns on six rules. strict adds no-network-in-components and honor-abort-signal, and raises cancel-before-optimistic from a warning to an error. The rules read syntax only, so they need no type information. The package README lists each rule.
@kontsedal/olas-codemod rewrites the mechanical 0.8 → 1.0 changes and lists the sites that need a person:
npx @kontsedal/olas-codemod 1.0Run it on a clean git tree, with the 0.8 packages still installed. MIGRATING.md covers what it changes and what it leaves to you.
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.@kontsedal/olas-eslint-plugin— the same invariants as lint errors, so drift fails the build instead of a review.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. |
| Cross-cutting behavior | Plugins, from definePlugin({ name, setup(host) }), observe every cache write and mutation, wrap fetches and mutations, and provide services through scopes. PLUGINS.md is the authoring guide. |
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, plugins, SSR, devtools event bus. Test helpers on @kontsedal/olas-core/testing. |
@kontsedal/olas-react |
React adapter — OlasProvider, useRoot, useValue, useQuery, useSuspenseQuery, useInfiniteQuery, useField, useMutation, SuspendOnUnmount, useSuspendOnHidden, HydrationBoundary, streaming SSR. Runs under Preact through preact/compat. |
@kontsedal/olas-vue |
Vue 3 adapter — olasPlugin, useRoot, and useValue, useQuery, useInfiniteQuery, useField and useMutation as refs. |
@kontsedal/olas-svelte |
Svelte adapter — setRoot / getRoot, plus queryStore, infiniteQueryStore, fieldStore and mutationStore. A signal is a Svelte store as it is. |
@kontsedal/olas-persist |
createPersisted for a signal, persistQueryCachePlugin for the query cache, over localStorageAdapter() or indexedDbAdapter(). |
@kontsedal/olas-zod |
zodValidator(schema) + createZodForm(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 plugin. A query opts in with meta: { crossTab: true }. |
@kontsedal/olas-entities |
defineEntity + auto-walk + reverse-index backprop for normalized entities across queries. The store is a service: ctx.inject(Entities). |
@kontsedal/olas-realtime |
createRealtimePatcher, createLiveStream and createConnectionState over a consumer-supplied RealtimeService. |
@kontsedal/olas-mutation-queue |
Best-effort, replay-safe mutation queue. Persists runs of a defineMutation({ meta: { persist: true } }) to a StorageAdapter; replays pending entries on reload / crash / reconnect (Web-Locks-coordinated cross-tab). |
@kontsedal/olas-router |
Generic router bridge — createRouterAdapter() returns a plugin that provides RouteParamsScope / RouteSearchScope / RoutePathnameScope, and a Bridge component that feeds them. Works with TanStack Router or React Router v6. |
@kontsedal/olas-eslint-plugin |
Eight syntax-only lint rules, with recommended and strict flat configs. |
@kontsedal/olas-codemod |
The 0.8 → 1.0 migration: npx @kontsedal/olas-codemod 1.0. |
Versioning. Each package versions independently. A release bumps only the packages that changed, so version numbers across the suite will not match and are not meant to. Install whichever packages you use at whatever versions npm resolves. Each declares the range of @kontsedal/olas-core it works with as a peer dependency, so an incompatible combination fails at install time rather than at runtime.
Five runnable example apps live in examples/. Each is a real (small) application — not a snippet — with its own dev server.
| App | Stack | What it shows |
|---|---|---|
stock-ticker |
Vanilla TS — no UI framework | Signals, computed, effect, emitter, throttled/debounced, defineQuery + refetchInterval, createPersisted watchlist, SVG sparklines. |
kanban |
React + Devtools | All three mutation concurrency modes, optimistic snapshot rollback, createZodForm + FieldArray, defineScope, entities, cross-tab sync, realtime, a tracing plugin, error-toast retry, activity feed, mounted <DevtoolsLauncher>. |
reader-ssr |
React + SSR | waitForIdle → dehydrate → hydrate round-trip, paginated defineQuery, useSuspendOnHidden, createPersisted × 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. |
vue-tasks |
Vue 3 | One controller with a query, an optimistic toggle, a canonical write and a validated form, read from single-file components. |
pnpm install
pnpm --filter @kontsedal/olas-example-kanban dev # or stock-ticker, reader-ssr, virtualized-table, vue-tasks
pnpm --filter @kontsedal/olas-example-kanban testEvery example ships a tests/ suite, and CI runs all five with pnpm --filter "./examples/*" test. Most of those tests build a root or call createTestController from @kontsedal/olas-core/testing, and drive the api in Node. A few render a component, where the rendered output is the thing under test.
These are honest, terse sketches. None of them are reasons to leave a tool you're happy with.
vs. Redux Toolkit and 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 and 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 composables built from the primitives: debounced writes, pagination, submit flows, inline edit, realtime patching.PLUGINS.md— writing a plugin: the host, the hooks, middleware, services and testing.MIGRATING.md— upgrading from 0.8, and 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 and 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 build # tsdown per package → dist/{js,d.ts} (ESM only)
pnpm typecheck # tsc --noEmit per package
pnpm lint # biome check .
pnpm test # vitest run (all packages)
pnpm check:doc-snippets # typecheck the TypeScript blocks in the docs
pnpm wiki:lint # check .wiki/ for broken refsCI = install → build → typecheck → lint → doc snippets → test → examples → publint → attw → smoke:dist → check:public-types → api:check → size. 2,533 tests across 187 files (including a cross-package packages/integration suite), all green.
MIT.