A lightweight, reactive JavaScript library for creating dynamic DOM elements with zero dependencies
β¨ Zero dependencies - No external libraries required
π XSS safe - Built-in protection against cross-site scripting
π TypeScript support - Full type safety and IntelliSense
β‘ Lightweight - Minimal footprint for optimal performance
π― Reactive - Automatic DOM updates when data changes
npm install marjoram
pnpm add marjoram
import { html, useViewModel } from 'marjoram';
// Create reactive state
const viewModel = useViewModel({
name: 'World',
count: 0
});
// Create reactive view
const view = html`
<div>
<h1>Hello, ${viewModel.$name}!</h1>
<p>Count: ${viewModel.$count}</p>
<button ref="increment">+</button>
</div>
`;
// Add event listeners
const { increment } = view.collect();
increment.addEventListener('click', () => {
viewModel.count++; // Automatically updates the DOM
});
// Append to DOM
document.body.appendChild(view);
html
- Template Literal FunctionCreates a reactive DOM view from a template literal.
function html(strings: TemplateStringsArray, ...args: unknown[]): View
Returns: A View
(enhanced DocumentFragment) with reactive capabilities.
Example:
import { html } from 'marjoram';
const view = html`
<div>
<h1 ref="title">Welcome!</h1>
<p>Static content</p>
</div>
`;
document.body.appendChild(view);
Use the ref
attribute to create element references:
const view = html`
<div>
<button ref="saveBtn">Save</button>
<button ref="cancelBtn">Cancel</button>
</div>
`;
const { saveBtn, cancelBtn } = view.collect();
saveBtn.addEventListener('click', () => {
console.log('Saving...');
});
useViewModel
- Reactive State ManagementCreates a reactive view model that automatically updates connected views.
function useViewModel<T extends Model>(model: T): ViewModel<T>
Parameters:
model
- Initial data object to make reactiveReturns: A proxied object that tracks changes and updates views automatically.
import { html, useViewModel } from 'marjoram';
const viewModel = useViewModel({
name: 'John',
age: 30,
active: true
});
const view = html`
<div>
<h2>${viewModel.$name}</h2>
<p>Age: ${viewModel.$age}</p>
<p>Status: ${viewModel.$active.compute(v => v ? 'Active' : 'Inactive')}</p>
</div>
`;
// Updates automatically trigger DOM changes
viewModel.name = 'Jane';
viewModel.age = 25;
$
Prefix ConventionWhen using reactive properties in templates, prefix them with $
:
viewModel.name
- Gets/sets the actual valueviewModel.$name
- Gets the reactive property for template bindingconst viewModel = useViewModel({ message: 'Hello' });
// β
Correct - use $ prefix in templates
const view = html`<p>${viewModel.$message}</p>`;
// β Incorrect - won't be reactive
const view = html`<p>${viewModel.message}</p>`;
// β
Correct - no $ prefix for getting/setting values
viewModel.message = 'Updated!'; // DOM updates automatically
Create derived values that update automatically:
const viewModel = useViewModel({
firstName: 'John',
lastName: 'Doe'
});
const fullName = viewModel.$firstName.compute((first) =>
`${first} ${viewModel.lastName}`
);
const view = html`
<div>
<p>Full name: ${fullName}</p>
</div>
`;
// Computed value updates automatically
viewModel.firstName = 'Jane';
import { html, useViewModel } from 'marjoram';
interface Todo {
id: number;
text: string;
completed: boolean;
}
const TodoApp = () => {
const viewModel = useViewModel({
todos: [] satisfies Todo[],
newTodo: '',
filter: 'all' satisfies 'all' | 'active' | 'completed'
});
const filteredTodos = viewModel.$todos.compute(todos => {
switch (viewModel.filter) {
case 'active': return todos.filter(t => !t.completed);
case 'completed': return todos.filter(t => t.completed);
default: return todos;
}
});
const view = html`
<div class="todo-app">
<h1>Todo List</h1>
<div class="input-section">
<input
ref="newTodoInput"
placeholder="What needs to be done?"
value="${viewModel.$newTodo}"
/>
<button ref="addBtn">Add</button>
</div>
<div class="filters">
<button ref="allFilter" class="${viewModel.$filter.compute(f => f === 'all' ? 'active' : '')}">All</button>
<button ref="activeFilter" class="${viewModel.$filter.compute(f => f === 'active' ? 'active' : '')}">Active</button>
<button ref="completedFilter" class="${viewModel.$filter.compute(f => f === 'completed' ? 'active' : '')}">Completed</button>
</div>
<ul class="todo-list">
${filteredTodos.compute(todos => todos.map(todo => html`
<li class="${todo.completed ? 'completed' : ''}">
<input
type="checkbox"
${todo.completed ? 'checked' : ''}
data-id="${todo.id}"
/>
<span>${todo.text}</span>
<button class="delete" data-id="${todo.id}">Γ</button>
</li>
`))}
</ul>
</div>
`;
// Event handlers
const { newTodoInput, addBtn, allFilter, activeFilter, completedFilter } = view.collect();
addBtn.addEventListener('click', () => {
if (viewModel.newTodo.trim()) {
viewModel.todos = [...viewModel.todos, {
id: Date.now(),
text: viewModel.newTodo.trim(),
completed: false
}];
viewModel.newTodo = '';
newTodoInput.value = '';
}
});
allFilter.addEventListener('click', () => viewModel.filter = 'all');
activeFilter.addEventListener('click', () => viewModel.filter = 'active');
completedFilter.addEventListener('click', () => viewModel.filter = 'completed');
return view;
};
document.body.appendChild(TodoApp());
import { html, useViewModel } from 'marjoram';
const AnimatedCounter = () => {
const viewModel = useViewModel({
count: 0,
isAnimating: false
});
const view = html`
<div class="counter ${viewModel.$isAnimating.compute(v => v ? 'animating' : '')}">
<h2>Count: ${viewModel.$count}</h2>
<div class="controls">
<button ref="decrement">-</button>
<button ref="reset">Reset</button>
<button ref="increment">+</button>
</div>
</div>
`;
const { increment, decrement, reset } = view.collect();
const animateChange = (callback: () => void) => {
viewModel.isAnimating = true;
callback();
setTimeout(() => {
viewModel.isAnimating = false;
}, 300);
};
increment.addEventListener('click', () => {
animateChange(() => viewModel.count++);
});
decrement.addEventListener('click', () => {
animateChange(() => viewModel.count--);
});
reset.addEventListener('click', () => {
animateChange(() => viewModel.count = 0);
});
return view;
};
import { html, useViewModel } from 'marjoram';
interface User {
id: number;
name: string;
email: string;
}
const UserList = () => {
const viewModel = useViewModel({
users: [] satisfies User[],
loading: false,
error: null satisfies string | null
});
const loadUsers = async () => {
viewModel.loading = true;
viewModel.error = null;
try {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
const users = await response.json();
viewModel.users = users;
} catch (err) {
viewModel.error = 'Failed to load users';
} finally {
viewModel.loading = false;
}
};
const view = html`
<div class="user-list">
<h2>Users</h2>
<button ref="loadBtn">Load Users</button>
${viewModel.$loading.compute(v => v ? html`<p>Loading...</p>` : '')}
${viewModel.$error.compute(v => v ? html`<p class="error">${v}</p>` : '')}
<ul>
${viewModel.$users.compute(users => users.map(user => html`
<li>
<strong>${user.name}</strong>
<br>
<small>${user.email}</small>
</li>
`))}
</ul>
</div>
`;
const { loadBtn } = view.collect();
loadBtn.addEventListener('click', loadUsers);
return view;
};
import { html, useViewModel } from 'marjoram';
const ContactForm = () => {
const viewModel = useViewModel({
name: '',
email: '',
message: '',
errors: {} satisfies Record<string, string>,
submitted: false
});
const validate = () => {
const errors: Record<string, string> = {};
if (!viewModel.name.trim()) {
errors.name = 'Name is required';
}
if (!viewModel.email.trim()) {
errors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(viewModel.email)) {
errors.email = 'Email is invalid';
}
if (!viewModel.message.trim()) {
errors.message = 'Message is required';
}
viewModel.errors = errors;
return Object.keys(errors).length === 0;
};
const view = html`
<form class="contact-form" ref="form">
<h2>Contact Us</h2>
<div class="field">
<label>Name:</label>
<input ref="nameInput" type="text" value="${viewModel.$name}" />
${viewModel.$errors.compute(errors =>
errors.name ? html`<span class="error">${errors.name}</span>` : ''
)}
</div>
<div class="field">
<label>Email:</label>
<input ref="emailInput" type="email" value="${viewModel.$email}" />
${viewModel.$errors.compute(errors =>
errors.email ? html`<span class="error">${errors.email}</span>` : ''
)}
</div>
<div class="field">
<label>Message:</label>
<textarea ref="messageInput">${viewModel.$message}</textarea>
${viewModel.$errors.compute(errors =>
errors.message ? html`<span class="error">${errors.message}</span>` : ''
)}
</div>
<button type="submit" ref="submitBtn">Send Message</button>
${viewModel.$submitted.compute(v => v ? html`
<div class="success">Message sent successfully!</div>
` : '')}
</form>
`;
const { form, nameInput, emailInput, messageInput } = view.collect();
// Sync inputs with view model
nameInput.addEventListener('input', (e) => {
viewModel.name = (e.target as HTMLInputElement).value;
});
emailInput.addEventListener('input', (e) => {
viewModel.email = (e.target as HTMLInputElement).value;
});
messageInput.addEventListener('input', (e) => {
viewModel.message = (e.target as HTMLTextAreaElement).value;
});
form.addEventListener('submit', (e) => {
e.preventDefault();
if (validate()) {
viewModel.submitted = true;
// Reset form after 3 seconds
setTimeout(() => {
viewModel.name = '';
viewModel.email = '';
viewModel.message = '';
viewModel.submitted = false;
viewModel.errors = {};
}, 3000);
}
});
return view;
};
Marjoram is built with TypeScript and provides excellent type safety:
import { html, useViewModel, View, ViewModel } from 'marjoram';
// Type-safe view models
interface AppState {
user: {
name: string;
age: number;
};
theme: 'light' | 'dark';
}
const viewModel: ViewModel<AppState> = useViewModel({
user: { name: 'John', age: 30 },
theme: 'light'
});
// Type-safe refs
const view: View = html`
<div class="${viewModel.$theme}">
<h1>${viewModel.$user.name}</h1>
</div>
`;
const refs: { header: HTMLElement } = view.collect();
Marjoram delivers optimal performance through careful design:
Performance comparison for common web application operations:
Operation | Marjoram | Vanilla JS | React | Vue 3 | Svelte | Advantage |
---|---|---|---|---|---|---|
Bundle Size | 4.8KB | 0KB | 42.2KB | 34.1KB | 9.6KB | 89% smaller than React |
Create 1K Items | 12ms | 8ms | 18ms | 15ms | 10ms | 33% faster than React |
Update 1K Items | 6ms | 4ms | 12ms | 9ms | 7ms | 50% faster than React |
Memory (1K items) | 2.1MB | 1.8MB | 3.4MB | 2.9MB | 2.3MB | 38% less than React |
Benchmarks conducted on MacBook Pro M1, Chrome 118. Results may vary by device and use case.
To implement your own benchmarks:
npm install && npm test # Includes performance tests
npm run build # Analyze bundle size
Custom benchmark scripts can be added to the /benchmarks
directory. Performance results are device and browser dependent.
While Marjoram provides powerful reactive capabilities, there are some architectural limitations to be aware of:
Deep nested property updates donβt automatically trigger reactive updates:
const viewModel = useViewModel({
user: {
profile: {
name: "John"
}
}
});
const view = html`<div>${viewModel.user.profile.name}</div>`;
// β This won't trigger a DOM update
viewModel.user.profile.name = "Jane";
// β
Workaround: Reassign the parent object
viewModel.user = {
...viewModel.user,
profile: { ...viewModel.user.profile, name: "Jane" }
};
Direct array mutations (push, pop, splice, etc.) donβt trigger reactive updates:
const viewModel = useViewModel({ items: ['a', 'b', 'c'] });
const view = html`
<ul>
${viewModel.$items.compute(items => items.map(item => html`<li>${item}</li>`))}
</ul>
`;
// β These won't trigger DOM updates
viewModel.items.push('d');
viewModel.items.pop();
// β
Workaround: Reassign the entire array
viewModel.items = [...viewModel.items, 'd'];
viewModel.items = viewModel.items.slice(0, -1);
Adding new properties to nested objects may not work as expected:
const viewModel = useViewModel({ config: { theme: 'dark' } });
// β This might not work reliably
viewModel.config.newProperty = 'value';
// β
Better: Add properties at the top level
viewModel.newProperty = 'value';
β Fully Supported - Marjoram now provides complete TypeScript support for array methods:
const viewModel = useViewModel({ items: [1, 2, 3] });
// β
Full type safety - no casting needed
const doubled = viewModel.$items.map(x => x * 2);
const evens = viewModel.$items.filter(x => x % 2 === 0);
const hasThree = viewModel.$items.includes(3);
const total = viewModel.$items.reduce((sum, x) => sum + x, 0);
// All common array methods are supported with proper types
Available methods: map
, filter
, forEach
, find
, reduce
, includes
, indexOf
, slice
, concat
, join
, some
, every
, findIndex
, length
These limitations are known and being addressed in future versions:
For current workarounds and best practices, see the examples section above.
We welcome contributions! Please see our Contributing Guide for details.
# Clone the repository
git clone https://github.com/SBRoberts/marjoram.git
# Install dependencies
npm install
# Run tests
npm test
# Build the library
npm run build
# Start development server
npm run dev
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm run test:coverage
MIT License - see the LICENSE file for details.
Made with β€οΈ by Spencer Rose