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
// 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.
# Navigate and start development
cd my-app
npm install
npm run dev
Architecture
Okalit enforces a hierarchical architecture designed for massive scalability.
App (AppMixin)
└── Module (ModuleMixin)
└── Page (PageMixin)
└── Component (Okalit)
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
Okalit Base Class
The reactive foundation for every component. Extends LitElement and
replaces Lit's property system with uhtml signals.
@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.
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:
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.
- 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 (userName → user-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 |
- 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.
computed(fn)
Derives a value from other signals. Cached until dependencies change.
effect(fn)
Runs a side-effect whenever accessed signals change.
batch(fn)
Groups multiple writes into a single update cycle.
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.
Channels
Cross-component reactive state without prop-drilling. Singleton channels shared across the entire app.
Defining a Channel
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
@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:
- 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:
- ephemeral: true,
});
// Publishing:
this.toast.set({ message: 'Saved!', type: 'success' });
// Subscribing (in another component):
static channels = { toast: ToastChannel('onToast') };
onToast(payload) { /* { message, type } */ }
Router
History-based SPA router with nested routes, lazy loading, guards, interceptors, and dynamic parameters.
Route Definitions
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
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.
Guards & Interceptors
Async functions that control navigation flow. Run before lazy loading.
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.
Services (REST)
Singleton services with built-in caching, interceptors, and the
declarative fire() API.
Defining a Service
@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
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
- 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.
@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()
Async: await
| 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) |
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
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
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
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>
- - `;
- }
}
Internationalization
Reactive translations powered by signals. Switch languages without reloading.
Setup
Place JSON files in public/i18n/:
{
- "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:
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
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.
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
<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. |