blokjs

BlokJS

Zero-build, zero-dependency, standalone, reactive, lightweight UI framework. ~31KB minified (~11KB gzipped).

Documentation - Examples - LLM Reference (~4,700 tokens) - npm

Quick start

<script src="https://cdn.jsdelivr.net/npm/@maleta/blokjs/dist/blokjs.min.js"></script>
<div id="app"></div>
<script>
  blok.mount('#app', {
    state: { count: 0 },
    methods: {
      inc() { this.count++ },
      dec() { this.count-- },
    },
    view: ($) => ({
      div: { children: [
        { h1: { text: $.count } },
        { button: { click: 'dec', text: '-' } },
        { button: { click: 'inc', text: '+' } },
      ] }
    })
  })
</script>

No virtual DOM. Views are plain JavaScript objects. State is reactive - when count changes, the bound h1 updates automatically.

Features

Components

blok.component('TodoItem', {
  props: ['todo'],

  methods: {
    remove() { this.emit('remove', this.todo) }
  },

  view: ($) => ({
    li: { children: [
      { input: { type: 'checkbox', model: $.todo.done } },
      { span: { text: $.todo.text } },
      { button: { click: 'remove', text: 'x' } },
    ] }
  })
})

Use components by name in templates. Pass props, listen to events with on_ prefix:

{ each: $.todos, as: 'todo', key: 'id', children: [
  { TodoItem: { todo: $.todo, on_remove: 'handleRemove' } }
] }

Stores

Global state shared across components. Async methods get automatic loading/error tracking:

blok.store('auth', {
  state: { user: null },

  computed: {
    isLoggedIn() { return this.user !== null }
  },

  methods: {
    async login(email, password) {
      const res = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify({ email, password }),
      })
      this.user = await res.json()
    },
    logout() { this.user = null }
  }
})
// In templates
{ when: $.store.auth.loading.login, children: [
  { p: 'Signing in...' }
] }
{ when: $.store.auth.error.login, children: [
  { p: { class: 'error', text: $.store.auth.error.login } }
] }

// In methods
this.store.auth.login(email, pw)
this.store.auth.isLoggedIn

blok.store() returns the store, so code outside components can read, write and watch it:

const auth = blok.store('auth')    // look up a registered store
const stop = blok.watch(() => auth.$state, state => {
  localStorage.setItem('auth', JSON.stringify(state))
}, { deep: true })
auth.$state = { user: null }       // assigns each key, like Object.assign

Routing

blok.mount('#app', {
  routes: [
    { path: '/', component: 'Home' },
    { path: '/product/:id', component: 'ProductDetail' },
    { path: '/admin', component: 'Admin', guard: 'requireAuth' },
    { path: '*', component: 'NotFound' },
  ],

  guards: {
    requireAuth(to, from) {
      if (!this.store.auth.isLoggedIn) return '/login'
      return true
    }
  },

  view: ($) => ({
    div: { children: [
      { a: { href: '/', link: true, text: 'Home' } },
      { a: { href: '/admin', link: true, text: 'Admin' } },
      { div: { route: true } },
    ] }
  })
})

Access route data in components via this.route.params, this.route.query, and navigate programmatically with this.navigate('/path').

View DSL

Views are plain objects. The $ proxy creates reactive references resolved at render time. A ref is a path, not a value, so JS operators (!, ? :, >) do not see its value: use $.not, the function form, or a computed.

// Conditionals
{ when: $.isLoggedIn, children: [{ p: 'Welcome!' }] }
{ when: $.not.isLoggedIn, children: [{ p: 'Please log in' }] }

// Function form - for expressions with operators
{ when: ($) => !$.isLoggedIn, children: [{ p: 'Please log in' }] }
{ when: ($) => $.count > 0 && $.isReady, children: [{ p: 'Go!' }] }

// Loops
{ each: $.items, as: 'item', key: 'id', children: [
  { li: { text: $.item.name } }
] }

// Two-way binding
{ input: { type: 'text', model: $.search } }
{ select: { model: $.category, children: [...] } }

// JS properties (arrays/objects for web components)
{ 'data-grid': { props: { rows: $.rows } } }

// Classes (string, object, or array)
{ div: { class: { active: $.isActive, disabled: $.isOff } } }
{ li: { class: { active: ($) => $.item.id === $.selectedId } } }

// Events
{ button: { click: 'save', text: 'Save' } }
{ button: { click: $.save, text: 'Save' } }   // method ref
{ 'wa-dialog': { on: { 'wa-after-hide': 'onClose' } } }
{ button: { click: 'remove(item)', text: 'x' } }
{ form: { submit: 'handleSubmit', children: [...] } }

// Slots
{ Card: { title: 'Hello', children: [
  { p: 'This goes into the slot' }
] } }

Using with Vite

While BlokJS works without a build step, the Vite plugin adds automatic component and store registration from file directories, bundling into optimized output, and hot module replacement during development.

Scaffold a new project:

npm create blokjs my-app
cd my-app
npm install
npx vite

Or add to an existing Vite project:

npm install blokjs vite-plugin-blokjs
// vite.config.js
import { defineConfig } from 'vite'
import { blokjs } from 'vite-plugin-blokjs'

export default defineConfig({
  plugins: [blokjs()],
})
// main.js
import { mount } from '@maleta/blokjs'
import 'virtual:blokjs'

mount('#app', {
  view: ($) => ({ counter: {} }),
})

The plugin auto-discovers files in components/ and stores/ directories, registering each by filename. Files prefixed with _ are ignored.

components/counter.js   -> component('counter', ...)
stores/app.js           -> store('app', ...)
components/_helper.js   -> ignored

Using with PocketBase

@maleta/blokjs-pocketbase turns a PocketBase collection into a BlokJS store: records load into items, follow realtime changes, and get loading/error tracking like any async store method. pbAuth does the same for the signed-in user.

<script src="https://cdn.jsdelivr.net/npm/@maleta/blokjs/dist/blokjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.28/dist/pocketbase.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@maleta/blokjs-pocketbase/dist/blokjs-pocketbase.min.js"></script>
const pb = new PocketBase('/')
blok.store('todos', blokPocketbase.pbCollection(pb, 'todos', { sort: '-created' }))

blok.mount('#app', {
  mount() { this.store.todos.connect() },
  methods: {
    add() { this.store.todos.save({ title: 'New todo' }) },
  },
  view: ($) => ({ div: { children: [
    { button: { click: 'add', text: 'Add' } },
    { each: $.store.todos.items, as: 'todo', key: 'id', children: [
      { p: { text: $.todo.title } },
    ] },
  ] } }),
})

See the package README for the full API and examples/pocketbase for a runnable app.

Prerendering

Pages render in the browser, so clients that do not run JavaScript (crawlers, link previews, site reviews) see an empty mount node. blokjs-prerender writes the rendered HTML of each route into the page, and blok.mount() then adopts that HTML instead of rendering it again. The app keeps working without this step.

bunx blokjs-prerender index.html                # every route without :param, * or guard
bunx blokjs-prerender index.html --routes / /about --out dist
bunx blokjs-prerender index.html --inline --out dist   # one self-contained file per route

In a server or a custom build script, renderToString from @maleta/blokjs/server returns the same HTML:

import { component, renderToString } from '@maleta/blokjs/server'

const html = renderToString(appOptions, { url: '/about' })
// <div id="app" data-blok-ssr>${html}</div>

See the package README for what runs during prerendering and what does not.

Browser Compatibility

BlokJS supports all ES2020-compliant browsers (96%+ global coverage). IE is not supported.

Development

pnpm install
pnpm build       # build all packages
pnpm test        # run tests

License

MIT