Concept

What is Okalit?

Okalit is a progressive Web Components framework built on Lit, enhanced with signal-based reactivity via uhtml, modern TC39 decorators, and a modular enterprise architecture.

Zero Virtual DOM

Surgical updates via fine-grained signal reactivity.

Native Standard

Compiles to pure Web Components. No framework lock-in.

Enterprise Ready

Modular architecture with DI, guards, and interceptors.

Core Exports

import {
// Base class & reactivity Okalit, html, signal, computed, effect, batch,
// Decorator defineElement,
// Channels defineChannel, getChannel, getChannelValueStorage,
// Router Router, navigate,
// Mixins AppMixin, ModuleMixin, PageMixin,
// Services OkalitService, OkalitGraphqlService, RequestControl, service, inject,
// i18n t, getI18n,
// Performance OIdle, OWhen, OViewport,
// GraphQL gql,
} from '@okalit';

Installation

Bootstrap a new project in seconds using the official CLI.

# Create a new project npx @okalit/cli new my-app
# Navigate and start development
cd my-app
npm install
npm run dev

Architecture

Okalit enforces a hierarchical architecture designed for massive scalability.

Class Hierarchy
App (AppMixin)
└── Module (ModuleMixin)
    └── Page (PageMixin)
        └── Component (Okalit)
src/ Structure
src/
├── main-app.js          // Root (AppMixin)
├── app.routes.js        // Route definitions
├── channels/            // Global state
├── guards/              // Route guards
├── interceptors/        // Req interceptors
├── modules/             // Business domains
│   └── example/
│       ├── example.module.js
│       ├── example.routes.js
│       └── pages/
├── components/          // Shared UI
│   ├── atoms/
│   ├── molecules/
│   └── organisms/
├── services/            // API layer
└── styles/              // CSS
Core

Okalit Base Class

The reactive foundation for every component. Extends LitElement and replaces Lit's property system with uhtml signals.

import { Okalit, html, defineElement } from '@okalit';
@defineElement({
- tag: 'my-counter',
- styles: [css],
- props: [
- - { count: { type: Number, value: 0 } }
- ]
})
export class MyCounter extends Okalit {
- render() {
- - return html`
- - <p>Count: ${
this.count.value}</p>
- - <button @click=${
() => this.count.value++}>+1</button>
- -`
;
- -}
- }

Props (Reactive Properties)

Props are declared as an array of objects. Each becomes a signal on the component instance.

// Definition
props: [
- { count: { type: Number, value: 0 } },
- { name: { type: String, value: '' } },
- { visible: { type: Boolean, value: true } }
]
// Reading this.count.value // → 0
// Writing (triggers re-render)
this.count.value = 5;
this.count.value++;
Type Coercion from attribute
Number Number(value)
Boolean value !== null && value !== 'false'
String Raw string (default)

Output (Custom Events)

Emit events to parent components across Shadow DOM boundaries:

// Emit from child
this.output('item-selected', { id: 42 });
// Listen in parent template
html`<my-list @item-selected=${(e) => this.handleSelection(e.detail)}></my-list>`;

@defineElement

TC39 decorator to register a class as a Custom Element with props, styles, and route params.

@defineElement({
- tag: 'user-card', // Custom element tag (must contain hyphen)
- styles: [css], // Array of CSS strings
- props: [ // Reactive property definitions
- - { name: { type: String, value: 'Anonymous' } },
- - { level: { type: Number, value: 1 } }
- ],
- params: ['id'] // Route parameters (optional)
})
Option Type Description
tag string Custom element tag name
styles string[] CSS injected via adoptedStyleSheets
props PropDef[] Reactive property definitions (become signals)
params string[] Route parameters this component receives

Auto Attribute Sync: Props are automatically converted to observed attributes (userNameuser-name). If a tag is already registered, the decorator silently skips (safe for HMR).

Lifecycle Hooks

Override these methods in your component to hook into each phase:

Hook When it fires
onInit() Component connected to the DOM
onChange(changes) A prop signal's value changed
onFirstRender() After the very first DOM render
onBeforeRender() Before each render cycle
onAfterRender() After each render cycle
onDestroy() Component disconnected from the DOM
onChange(changes) {
- if (changes.count) {
- - console.log(
- - `count: ${changes.count.previous} → ${changes.count.current}`
- - );
- }
}

onInit() {
- // Ideal for subscriptions or initial data fetching
- this.userService.getProfile().fire({
- - onSuccess: (data) => this.user.value = data,
- });
}

onDestroy() { // All signal effects & channel subscriptions are auto-disposed }

Reactivity Primitives

Okalit re-exports uhtml's reactive primitives for fine-grained DOM updates:

signal(initialValue)

Creates a reactive signal. Subscribers re-run when .value changes.

const count = signal(0); count.value = 5; // triggers updates
computed(fn)

Derives a value from other signals. Cached until dependencies change.

const double = computed(() => count.value * 2);
effect(fn)

Runs a side-effect whenever accessed signals change.

effect(() => { console.log('Count:', count.value); });
batch(fn)

Groups multiple writes into a single update cycle.

batch(() => { this.firstName.value = 'John'; this.lastName.value = 'Doe'; }); // Only one re-render

Auto-tracking in render(): Signals read inside render() are automatically tracked via an internal effect(). When they change, only the affected DOM nodes update — no full re-render.

State

Channels

Cross-component reactive state without prop-drilling. Singleton channels shared across the entire app.

Defining a Channel

import { defineChannel } from '@okalit';
export const CounterChannel = defineChannel('app:counter', {
- initialValue: 0,
- persist: 'local', // 'memory' | 'local' | 'session'
- scope: 'app', // 'app' | 'module' | 'page'
});
Persist Storage Survives refresh?
memory In-memory signal No
session sessionStorage Yes (same tab)
local localStorage Yes
Scope Cleared on...
app Never (app lifetime)
module Navigation to different module
page Navigation to different page

Using in Components

import { CounterChannel } from '../channels/counter.channel.js';
@defineElement({ tag: 'counter-display' })
export class CounterDisplay extends Okalit {
- static channels = {
- - count: CounterChannel(),
- };
- render() {
- - return html`<p>Count: ${this.count.value}</p>`;
- }
} // Reading: this.count.value // Writing: this.count.set(42)

Method Subscription

Pass a method name to receive updates reactively:

static channels = {
- count: CounterChannel('onCountChange'),
};

onCountChange(newValue) {
- console.log('Counter changed to:', newValue);
}

Ephemeral Channels (Event Bus)

No stored state — acts as a pub/sub event bus:

export const ToastChannel = defineChannel('ui:toast', {
- ephemeral: true,
});

// Publishing:
this.toast.set({ message: 'Saved!', type: 'success' });

// Subscribing (in another component):
static channels = { toast: ToastChannel('onToast') };

onToast(payload) { /* { message, type } */ }
Navigation

Router

History-based SPA router with nested routes, lazy loading, guards, interceptors, and dynamic parameters.

Route Definitions

// app.routes.js
export default [
{
- path: '/',
- component: 'home-module',
- import: () => import('./modules/home/home.module.js'),
- children: [
- - {
- - - path: '/',
- - - component: 'home-page',
- - - import: () => import('./modules/home/pages/home.page.js'),
- - }
- ]
},
{
- path: '/users',
- component: 'users-module',
- - import: () => import('./modules/users/users.module.js'),
- - guards: [authGuard],
- - children: [
- - - { path: '/', component: 'users-list', import: () => import('...') },
- - - { path: '/:id', component: 'user-detail', import: () => import('...') },
- - ]
- }
];
Property Type Description
path string URL pattern. Supports :param dynamic segments
component string Tag name of the custom element to render
import () => Promise Lazy-load function (called only on match)
children Route[] Nested child routes
guards Function[] Functions that run before the route loads

Navigation

// From any component with a mixin
this.navigate('/users/42');
// Programmatic (anywhere)
import { navigate } from '@okalit';
navigate('/dashboard');
navigate('/login', { replace: true });
// Access route params (in PageMixin)
this.routeParams // { id: '42' }
this.queryParams // { tab: 'posts' }

The <okalit-router> Outlet

Built-in custom element that renders matched route components. Knows its depth and picks the correct component from the chain.

render() { return html` <nav>...</nav> <okalit-router></okalit-router> `; }

Guards & Interceptors

Async functions that control navigation flow. Run before lazy loading.

// guards/auth.guard.js
import { getChannelValueStorage } from '@okalit';
export async function authGuard({ path, params, route }) {
- const token = getChannelValueStorage('session:token', 'local');
- if (!token) {
- - return '/login'; // Redirect
- }
- return true; // Allow
}
Return value Effect
true Allow navigation
false Block navigation (stays on current page)
string Redirect to the returned path

Guard inheritance: A parent route's guards run before child guards. The route chain is: parent guards → child guards → lazy import.

Data

Services (REST)

Singleton services with built-in caching, interceptors, and the declarative fire() API.

Defining a Service

import { OkalitService, service } from '@okalit';

@service('user')
class UserService extends OkalitService {
- constructor() {
- - super();
- - this.configure({
- - - baseUrl: 'https://api.example.com',
- - - cache: true,
- - - cacheTTL: 30000,
- - });
- }

- getUsers(params) {
- - return this.get('/users', params);
- }

- getUserById(id) {
- - return this.get(`/users/${id}`);
- }

- createUser(data) {
- - return this.post('/users', data);
- }

- updateUser(id, d) {
- - return this.put(`/users/${id}`, d);
- }

- deleteUser(id) {
- - return this.delete(`/users/${id}`);
- }
}

Injecting & Using

import { inject } from '@okalit';

const userApi = inject('user');

// Option 1: Declarative with fire()
userApi.getUsers().fire({
- onLoading: (loading) => this.isLoading.value = loading,
- onSuccess: (data) => this.users.value = data,
- onError: (err) => console.error(err),
- onFinish: () => console.log('Done'),
});

// Option 2: Async/Await
const users = await userApi.getUsers();
configure() option Type Description
baseUrl string Base URL for all requests
headers Record Default headers
cache boolean Enable in-memory response caching
cacheTTL number Cache lifetime in ms (0 = no expiry)
interceptors Function[] Request interceptors
responseInterceptors Function[] Response interceptors

Request & Response Interceptors

this.configure({
- interceptors: [
- - async ({ url, options }) => {
- - - options.headers['Authorization'] = `Bearer ${getToken()}`;
- - - return { url, options }; // continue // return null; // cancel request
- - }
- ],
- responseInterceptors: [
- - async ({ data, error }) => {
- - - if (error?.status === 401) navigate('/login');
- - - return { data, error };
- - }
- ],
});

GraphQL

The gql tagged template and OkalitGraphqlService for full GraphQL support.

import { OkalitGraphqlService, service, gql } from '@okalit';

@service('postGql')
class PostGraphqlService extends OkalitGraphqlService {
- constructor() {
- - super();
- - this.configure({
- - - endpoint: '/graphql',
- - - cache: true,
- - });
- }

- getPosts(page = 1) {
- - return this.query(
- - - gql` query GetPosts($page: Int!) { posts(page: $page) { id title author { name } } } `,
- - - { page }
- - );
- }

- likePost(id) {
- - return this.mutate(
- - - gql` mutation LikePost($id: ID!) { likePost(id: $id) { id likes } } `, { id }
- - );
- }
}
Method Description
query(str, vars?) Execute a GraphQL query → RequestControl
mutate(str, vars?) Execute a mutation → RequestControl
clearCache(str?) Clear cached queries (all or matching)

Error handling: GraphQL errors include err.graphql = true, err.errors (array), and err.data (partial). Network errors include err.status and err.body.

RequestControl

Every HTTP method returns a RequestControl — both await-able and declaratively consumable.

Declarative: fire()

api.getUsers().fire({ onLoading: (l) => ..., onSuccess: (d) => ..., onError: (e) => ..., onFinish: () => ..., });

Async: await

const data = await api.getUsers(); // or with try/catch: try { const u = await api.getUserById(1); } catch (err) { ... }
Callback Signature When
onLoading (loading: boolean) => void true before, false after
onSuccess (data: any) => void Request succeeded
onError (error: any) => void Request failed
onFinish () => void Always (after success or error)
Architecture

Mixins

Composition mixins that build the architectural hierarchy.

AppMixin

Root level. Boots router & i18n.

  • this.router
  • this.navigate()
  • this.switchLocale()

ModuleMixin

Groups pages. Nested outlet.

  • this.router
  • this.navigate()
  • • Renders <okalit-router>

PageMixin

Route view. Access params.

  • this.routeParams
  • this.queryParams
  • this.navigate()

AppMixin — static config

@defineElement({ tag: 'main-app' })
export class MainApp extends AppMixin(Okalit) {
- static config = {
- - routes,
- - i18n: { default: 'en', locales: ['en', 'es'] },
- - template: (outlet) => html`
- - - <app-header></app-header>
- - - <main>${outlet}</main>
- - - <app-footer></app-footer>
- - `
,
- };
}

ModuleMixin

@defineElement({ tag: 'users-module' })
export class UsersModule extends ModuleMixin(Okalit) {
- // Default render() already provides <okalit-router>
- // Override only for module-level layout:
- render() {
- - return html`
- - - <module-sidebar></module-sidebar>
- - - <okalit-router></okalit-router>
- -`
;
- }
}

PageMixin

@defineElement({ tag: 'user-detail' })
export class UserDetail extends PageMixin(Okalit) {
- onInit() {
- - const id = this.routeParams.id;
- - // fetch user...
- }

- render() {
- - return html`
- - - <h1>User ${this.routeParams.id}</h1>
- - - <button @click=${() => this.navigate('/users')}>Back</button>
- - `
;
- }
}
i18n

Internationalization

Reactive translations powered by signals. Switch languages without reloading.

Setup

Place JSON files in public/i18n/:

// public/i18n/en.json
{
- "NAV": { "HOME": "Home", "SETTINGS": "Settings" },
- "WELCOME": { "TITLE": "Welcome, {{name}}!", "SUBTITLE": "You have {{count}} notifications" }
}

The t() Function

Global reactive translation helper — components re-render automatically when language changes:

import { t } from '@okalit';

render() {
- return html`
- - <h1>${t('WELCOME.TITLE', { name: 'Miguel' })}</h1>
- - <p>${t('WELCOME.SUBTITLE', { count: 5 })}</p>
- - <nav>
- - <a href="/">${t('NAV.HOME')}</a>
- - </nav>
- `
;
}

Switching Languages

// From AppMixin component
await this.switchLocale('es');
// From anywhere
import { getI18n } from '@okalit';
await getI18n().setLocale('fr');

Locale detection order: 1) localStorage (okalit:locale) → 2) Browser language → 3) Default from config. Translations are lazy-loaded on demand.

Optimization

Performance Directives

Three custom elements for deferring component loading: by idle time, by condition, or by viewport visibility.

<o-idle>

Loads when browser is idle (requestIdleCallback).

Non-critical UI, analytics widgets

<o-when>

Loads when a boolean condition becomes true.

Auth-gated, feature-flagged content

<o-viewport>

Loads when element enters the viewport (IntersectionObserver).

Below-fold content, lazy images

Usage

<!-- Load when browser is idle -->
<o-idle .loader=${() => import('./heavy-chart.js')}>
- <heavy-chart></heavy-chart>
- <span slot="fallback">Loading chart...</span>
</o-idle>

<!-- Load when condition is true -->
<o-when .loader=${() => import('./admin-panel.js')} .condition=${this.isAdmin.value} >
- <admin-panel></admin-panel>
</o-when>

<!-- Load when scrolled into view -->
<o-viewport .loader=${() => import('./comments.js')}>
- <comments-section></comments-section>
- <span slot="fallback">Loading...</span>
</o-viewport>
Feature Description
.loader Function or array of functions returning Promises (parallel via Promise.all)
[loaded] attr Set automatically when loading completes (CSS targetable)
slot="fallback" Content shown while loading (skeleton, spinner, etc.)
o-error event Dispatched if any loader fails
One-shot All directives load only once. Re-setting loader or condition is ignored after load.