Build fast reactive UIs in pure TypeScript/JavaScript without a virtual DOM.
Aberdeen's approach is refreshingly simple:
Use many small anonymous functions for emitting DOM elements, and automatically rerun them when their underlying data changes. JavaScript
Proxyis used to track reads and updates to this data, which can consist of anything, from simple values to complex, typed, and deeply nested data structures.
First, let's start with the obligatory reactive counter example. If you're reading this on the official website you should see a working demo below the code, and an 'edit' button in the top-right corner of the code, to play around.
import {$, proxy, ref} from 'aberdeen';
// Define some state as a proxied (observable) object
const state = proxy({question: "How many roads must a man walk down?", answer: 42});
$('h3', () => {
// This function reruns whenever the question or the answer changes
$('text=', `${state.question} ↪ ${state.answer || 'Blowing in the wind'}`)
});
// Two-way bind state.question to an <input>
$('input placeholder=Question bind=', ref(state, 'question'))
// Allow state.answer to be modified using both an <input> and buttons
$('div.row $marginTop=1em', () => {
$('button text=- click=', () => state.answer--);
$('input type=number bind=', ref(state, 'answer'))
$('button text=+ click=', () => state.answer++);
});
Okay, next up is a somewhat more complex app - a todo-list with the following behavior:
Pfew.. now let's look at the code:
import {$, proxy, onEach, insertCss, peek, unproxy, ref} from "aberdeen";
import {grow, shrink} from "aberdeen/transitions";
// We'll use a simple class to store our data.
class TodoItem {
constructor(public label: string = '', public done: boolean = false) {}
toggle() { this.done = !this.done; }
}
// The top-level user interface.
function drawMain() {
// Add some initial items. We'll wrap a proxy() around it!
let items: TodoItem[] = proxy([
new TodoItem('Make todo-list demo', true),
new TodoItem('Learn Aberdeen', false),
]);
// Draw the list, ordered by label.
onEach(items, drawItem, item => item.label);
// Add item and delete checked buttons.
$('div.row', () => {
$('button:+', {
click: () => items.push(new TodoItem("")),
});
$('button.outline:Delete checked', {
click: () => {
for(let idx in items) {
if (items[idx].done) delete items[idx];
}
}
});
});
};
// Called for each todo list item.
function drawItem(item) {
// Items without a label open in editing state.
// Note that we're creating this proxy outside the `div.row` scope
// create below, so that it will persist when that state reruns.
let editing: {value: boolean} = proxy(item.label == '');
$('div.row', todoItemStyle, {create:grow, destroy: shrink}, () => {
// Conditionally add a class to `div.row`, based on item.done
$({".done": ref(item,'done')});
// The checkmark is hidden using CSS
$('div.checkmark:✅');
if (editing.value) {
// Label <input>. Save using enter or button.
function save() {
editing.value = false;
item.label = inputElement.value;
}
let inputElement = $('input', {
placeholder: 'Label',
value: item.label,
keydown: e => e.key==='Enter' && save(),
});
$('button.outline:Cancel', {click: () => editing.value = false});
$('button:Save', {click: save});
} else {
// Label as text.
$('p:' + item.label);
// Edit icon, if not done.
if (!item.done) {
$('a:Edit', {
click: e => {
editing.value = true;
e.stopPropagation(); // We don't want to toggle as well.
},
});
}
// Clicking a row toggles done.
$({click: () => item.done = !item.done, $cursor: 'pointer'});
}
});
}
// Insert some component-local CSS, specific for this demo.
const todoItemStyle = insertCss({
marginBottom: "0.5rem",
".checkmark": {
opacity: 0.2,
},
"&.done": {
textDecoration: "line-through",
".checkmark": {
opacity: 1,
},
},
});
// Go!
drawMain();
Some further examples:
And you may want to study the examples above, of course!
Enhancements:
$ function now supports a more concise syntax for setting attributes and properties. Instead of writing $('p', 'button', {$color: 'red', click: () => ...}), you can now write $('p button $color=red click=', () => ...).proxy() function can now accept Promises, which will return an observable object with properties for busy status, error (if any), and the resolved value. This makes it easier to call async functions from within UI code.Breaking changes:
Promise, that will now be reported as an error. Async render functions are fundamentally incompatible with Aberdeen's reactive model, so it's helpful to point that out. Use the new proxy() async support instead.This major release aims to reduce surprises in our API, aligning more closely with regular JavaScript semantics (for better or worse).
Breaking changes:
onEach and map) will now only work on own properties of the object, ignoring those in the prototype chain. The new behavior should be more consistent and faster.undefined and empty. Previously, object/array/map items with undefined values were considered non-existent. The new behavior (though arguably confusing) is more consistent with regular JavaScript semantics.copy function no longer ..
SHALLOW and MERGE flags. The latter has been replaced by a dedicated merge function. The former turned out not to be particularly useful.observe function has been renamed to derive to better reflect its purpose and match terminology used in other reactive programming libraries.$({element: myElement}) syntax for inserting existing DOM elements has been removed. Use $(myElement) instead.route API brings some significant changes. Modifying the route observable (which should now be accessed as route.current) will now always result in changing the current browser history item (URL and state, using replaceState), instead of using a heuristic to figure out what you probably want. Dedicated functions have been added for navigating to a new URL (go), back to a previous URL (back), and for going up in the route hierarchy (up).immediateObserve function) no longer exists. It caused unexpected behavior (for instance due to the fact that an array pop() in JavaScript is implemented as a delete followed by a length change, so happens in two steps that would each call immediate observers). The reason it existed was mostly to enable a pre-1.0 version of the route API. It turned out to be a mistake.Enhancements:
peek function can no also accept an object and a key as argument (e.g. peek(obj, 'myKey')). It does the same as peek(() => obj.myKey), but more concise and faster.copy and merge functions now ..
dstKey argument, allowing you to assign to a specific key with copy semantics, and without subscribing to the key.dispatcher module has been added. It provides a simple and type-safe way to match URL paths to handler functions, and extract parameters from the path. You can still use your own routing solution if you prefer, of course.route module now also has tests, making the whole project now fully covered by tests.Fixes:
route module had some reliability issues after page reloads.copy and clone function created Maps and Arrays with the wrong internal type. So instanceof Array would say yes, while Array.isArray would say no. JavaScript is weird.After five years of working on this library on and off, I'm finally happy with its API and the developer experience it offers. I'm calling it 1.0! To celebrate, I've created some pretty fancy (if I may say so myself) interactive documentation and a tutorial.