Zog.js
Full reactivity with minimal code size.
Zog.js is a minimalist JavaScript library for building reactive user interfaces. It allows you to write clean, declarative templates directly in your HTML and power them with a simple, yet powerful, reactivity system. Inspired by the best parts of modern frameworks, Zog.js offers an intuitive developer experience with zero dependencies and no build step required.
Highlights
- Reactive primitives:
ref (primitives only), reactive (objects/arrays), computed
- Effects:
watchEffect with automatic dependency tracking
- Lightweight template compiler for declarative DOM binding and interpolation (
{{ }})
- Template directives:
z-if, z-for, z-text, z-html, z-show, z-model, z-on (shorthand @)
- App lifecycle:
createApp(...).mount(selector) and .unmount()
- Hook System: Extend and customize behavior with lifecycle hooks
- Plugin architecture:
app.use(plugin, options) for modular extensions
- Async effect queue: Batched updates with effect sorting for optimal performance
Installation
Via npm
npm install zogjs
Direct ES Module
<script type="module">
import { createApp, ref } from 'zog.js';
import { createApp, ref } from 'https://cdn.zogjs.com/0.4.7/zog.js';
</script>
Quick Start
Basic Counter Example
<!DOCTYPE html>
<html lang="en">
<head>
<title>Zog.js Counter</title>
</head>
<body>
<div id="app">
<h1>{{ title }}</h1>
<p>Current count: {{ count }}</p>
<button @click="increment">Increment</button>
<button @click="decrement">Decrement</button>
</div>
<script type="module">
import { createApp, ref } from './zog.js';
createApp(() => {
const title = ref('Counter App');
const count = ref(0);
const increment = () => count.value++;
const decrement = () => count.value--;
return { title, count, increment, decrement };
}).mount('#app');
</script>
</body>
</html>
Core Concepts
Reactivity Primitives
ref(primitive) — For primitive values only
Creates a reactive reference for strings, numbers, and booleans only.
const count = ref(0);
count.value++;
⚠️ Important (v0.4.7): ref() throws an error if passed an object or array. Use reactive() instead.
const user = ref({ name: 'John' });
const user = reactive({ name: 'John' });
reactive(object) — For objects and arrays
Returns a deep reactive proxy of an object or array.
const state = reactive({
user: { name: 'John', age: 30 },
todos: ['Learn Zog.js']
});
state.user.age = 31;
state.todos.push('Build an app');
Array reactivity: All array methods are fully reactive:
- Mutators:
push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin
- Iterators:
map, filter, find, findIndex, findLast, findLastIndex, every, some, forEach, reduce, reduceRight, flat, flatMap, values, entries, keys, includes, indexOf, lastIndexOf
computed(getter)
Creates a lazily evaluated, memoized reactive value.
const firstName = ref('John');
const lastName = ref('Doe');
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
console.log(fullName.value);
firstName.value = 'Jane';
console.log(fullName.value);
watchEffect(fn, opts?)
Runs a reactive effect immediately and re-runs when dependencies change.
const count = ref(0);
const stop = watchEffect(() => {
console.log('Count is:', count.value);
});
count.value++;
stop();
Template Interpolation
Text nodes containing {{ expression }} are automatically reactive:
<p>Hello, {{ name }}!</p>
<p>You have {{ items.length }} items.</p>
<p>Total: {{ price * quantity }}</p>
Template Directives
Conditional Rendering
z-if, z-else-if, z-else: Conditionally render elements.
<div z-if="score >= 90">Excellent!</div>
<div z-else-if="score >= 70">Good job!</div>
<div z-else>Keep trying!</div>
List Rendering
z-for: Repeat elements for each item in an array.
<li z-for="item in items">{{ item }}</li>
<li z-for="(item, index) in items">
{{ index + 1 }}. {{ item.name }}
</li>
<li z-for="item in items" :key="item.id">
{{ item.name }}
</li>
z-for behavior (v0.4.7):
- Object items are reactive (direct property access)
- Primitive items are ref-wrapped (auto-unwrapped in templates)
- Index is a plain number that updates correctly when array changes
- Always use
:key with unique IDs for performance
Content Directives
<p z-text="message"></p>
<div z-html="htmlContent"></div>
<div z-show="isVisible">...</div>
Two-Way Binding
z-model: Bind form inputs bidirectionally.
<input z-model="username" />
<textarea z-model="bio"></textarea>
<input type="checkbox" z-model="agreed" />
<input type="radio" z-model="color" value="red" />
<select z-model="country">
<option value="us">United States</option>
</select>
Event Handling
@event or z-on:event: Attach event listeners.
<button @click="handleClick">Click me</button>
<button @click="count.value++">Increment</button>
Attribute Binding
:attribute: Dynamically bind any attribute.
<img :src="imageUrl" :alt="imageAlt" />
<button :disabled="isDisabled">Submit</button>
<div :class="{ active: isActive, error: hasError }">Content</div>
<div :style="{ color: textColor, fontSize: size + 'px' }">Text</div>
Hook System
import { onHook } from './zog.js';
onHook('beforeCompile', (el, scope, cs) => {
console.log('Compiling:', el.tagName);
});
onHook('onError', (error, context, details) => {
console.error(`Error in ${context}:`, error);
});
Plugin System
Creating a Plugin
export const MyPlugin = {
install(api, options) {
api.onHook('beforeCompile', (el, scope, cs) => {
if (el.hasAttribute('z-focus')) {
el.removeAttribute('z-focus');
setTimeout(() => el.focus(), 0);
}
});
}
};
Using Plugins
import { createApp } from './zog.js';
import { MyPlugin } from './my-plugin.js';
createApp(() => ({ }))
.use(MyPlugin, { debug: true })
.mount('#app');
Complete Example: Todo List
<div id="app">
<input z-model="newTodo" @keyup.enter="addTodo" placeholder="Add todo" />
<button @click="addTodo">Add</button>
<ul>
<li z-for="(todo, index) in todos" :key="todo.id">
<input type="checkbox"
:checked="todo.done"
@change="toggleTodo(todo.id)" />
<span :class="{ done: todo.done }">
{{ index + 1 }}. {{ todo.text }}
</span>
<button @click="removeTodo(todo.id)">×</button>
</li>
</ul>
<p z-show="todos.length === 0">No todos yet!</p>
<p>{{ remaining }} of {{ todos.length }} remaining</p>
</div>
<script type="module">
import { createApp, ref, reactive, computed } from './zog.js';
createApp(() => {
const newTodo = ref('');
const todos = reactive([]);
let nextId = 1;
const remaining = computed(() => todos.filter(t => !t.done).length);
function addTodo() {
if (!newTodo.value.trim()) return;
todos.push({ id: nextId++, text: newTodo.value, done: false });
newTodo.value = '';
}
function removeTodo(id) {
const idx = todos.findIndex(t => t.id === id);
if (idx > -1) todos.splice(idx, 1);
}
function toggleTodo(id) {
const todo = todos.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}
return { newTodo, todos, remaining, addTodo, removeTodo, toggleTodo };
}).mount('#app');
</script>
API Reference
ref(primitive) | Reactive reference for primitives only |
reactive(object) | Deep reactive proxy for objects/arrays |
computed(getter) | Cached computed value |
watchEffect(fn, opts?) | Auto-tracking reactive effect |
createApp(setup) | Create app with .mount(), .unmount(), .use() |
nextTick(fn) | Execute after DOM update |
onHook(name, fn) | Register lifecycle hook |
Directive Reference
{{ expr }} | <p>{{ message }}</p> |
z-if / z-else-if / z-else | <div z-if="show">Text</div> |
z-for | <li z-for="item in items" :key="item.id"> |
z-model | <input z-model="value" /> |
z-show | <div z-show="visible"> |
z-text / z-html | <p z-text="msg"></p> |
@event | <button @click="handler"> |
:attr | <img :src="url" /> |
:class | <div :class="{ active: isActive }"> |
:style | <div :style="{ color: c }"> |
Browser Support
Requires ES6 Proxy support:
- Chrome 49+, Firefox 18+, Safari 10+, Edge 12+
- ❌ Internet Explorer
Bundle Size
- ~5KB minified
- Zero dependencies
- No build step required
Changelog
v0.4.7 (Current)
- ✅ Added comprehensive code documentation
- ✅ Removed unused code
v0.4.6
Breaking Changes:
- ⚠️
ref() now only accepts primitive values (string, number, boolean)
- ⚠️ Use
reactive() for objects and arrays
Bug Fixes:
- 🐛 Fixed z-for index reactivity (index now updates correctly when array changes)
- 🐛 Restored effect sorting by ID for correct execution order
- 🐛 Added expression cache limit (500) to prevent memory leaks
Improvements:
- ✨ Plugin API now receives full access:
reactive, ref, computed, watchEffect, onHook, compile, Scope, evalExp
- ✨ Cleaner separation between
ref (primitives) and reactive (objects)
- ✨ Improved Scope management with parent-child relationships
v0.3.2
- ✨ Added Hook System (
beforeCompile, afterCompile, beforeEffect, onError)
- ✨ Plugin API with access to hooks and utilities
- 🚀 Optimized effect queue management
Migration from v0.3.x to v0.4.x
Breaking Change: ref() no longer accepts objects/arrays.
const user = ref({ name: 'John' });
user.value.name = 'Jane';
const user = reactive({ name: 'John' });
user.name = 'Jane';
License
MIT License - Free to use in commercial and non-commercial projects.
Made with ❤️ for simplicity.