Skip to content

Kontsedal/olas

Repository files navigation

Olas

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.


Table of contents


Why

Most apps end up in one of three places:

  1. 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.
  2. One big store. Scalable but blunt. Everything is global, ownership is implicit, lifetimes are forever.
  3. 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. No render(<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-core never imports React. The React adapter is ~230 lines on top of useSyncExternalStore. The same controllers can drive Vue, Svelte, or vanilla DOM with a small adapter.

Install

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.


A five-minute tour

Six concepts, each smaller than the last. By the end you can read any Olas codebase.

1. Signals — typed boxes that notify

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.

2. Controllers — group state and behavior

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, everything

deps is required (more on this in Dependency injection). For trivial apps, {} is fine.

3. Reading from React

// 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.

4. Async data with defineQuery

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).

5. Writes with mutations

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.

6. Forms

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.


Common recipes

Dependency injection

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.

Cross-controller communication — emitters

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 }
})

Optimistic UI with rollback

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.

Persisted state

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.

SSR — dehydrate and hydrate

// 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.

Devtools

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.


Working with AI assistants

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.ts plus its test file. It iterates with pnpm vitest run packages/foo/tests/someController.test.ts against createTestController — no jsdom, no Testing Library, no fake DOM tree.
  • For UI work, export type FooApi from the controller and hand it plus the React file to the assistant. It maps api.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.


How it scales

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.


Packages

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.


Examples

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 test

Every 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.


How it compares

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.


Learn more

  • 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.M section.
  • 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.

Commands

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 refs

CI = install → typecheck → lint → test → build. 621 tests across 55 files (including a cross-package packages/integration suite), all green.


License

MIT.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors