Elementary
Elementary is a simple reactive front-end library. It provides
Reactor
objects that store reactive variablesObserver
functions that automatically track the reactive variables that they use and retrigger if any of these variables are updated. The function ob
is shorthand for new Observer
- A function
el
that allows declarative element creation in javascript
Here's a quick example of what Elementary does:
import { Reactor, ob, el } from '@fynyky/elementary'
const rx = new Reactor({
name: 'Anakin'
})
el(document.body,
el('main',
el('h1', 'Hello World!'),
el('h2', (x) => { x.id ='foo' }, () => 'returned text'),
el('defaults to div', ['this', 'is', 'an', 'array']),
el('p more class names', ob(() => ('My name is ' + rx.name)))
)
)
rx.name = 'Darth'
Reactor
objects work like normal objects that you can set and get properties onObserver
functions work like normal functions that you can define and call- When an
Observer
reads a Reactor
it registers itself as a dependent - When a
Reactor
is updated it automatically retriggers the dependent Observer
functions el
creates a DOM element
- The first argument is the type of element it creates
- Subsequent arguments are appended as children
- Function children are run with the context of the parent element
- Function return values are appended as children
Observer
functions automatically replace their child nodes when retriggered
Elementary is meant to be unobtrusive and unopinionated.
- No special syntax to learn. Everything is just plain javascript
- There is no need to manually declare listeners or bindings. Elementary automatically keeps track of all that for you.
- Use it for the whole front-end or just a few components. Elements created by Elementary are just normal DOM elements, and any variable can be easily replaced with a reactive one without changing the rest of your codebase.
You try it yourself in a JSFiddle here
Installation
Elementary is available on npm. Install it by running:
$ npm install @fynyky/elementary
Import it using:
import {
el,
ob,
attr,
bind,
Reactor,
Observer,
hide,
batch,
shuck
} from '@fynyky/elementary'
It is also available directly from unpkg. You can import it in javascript using
import { el, attr, bind, ob, Reactor, Observer, hide, batch, shuck } from 'https://unpkg.com/@fynyky/elementary'
Elements
The function el(description, children...)
builds DOM elements. It appends the children
arguments to a parent element created/referenced by the description
.
The description
can be a String
or an existing Element
. If given a String
it creates a new Element
whose classes are the entire description, and the tag type is the first word of the description.
el('h1 foo bar')
<h1 class="h1 foo bar"></h1>
If the first word is not a valid HTML tag it defaults to making a div
el('foo bar')
<div class="foo bar"></div>
If given an existing Element
it does nothing on its own but uses the provided element as a target for applying the children
arguments. For example you can append things to the document body by doing
el(document.body, 'hello world')
<html>
<body>hello world</body>
</html>
For convenience, if the string provided starts with #
or .
then instead of creating a new element, the description will be used as a selector and will try to find an existing matching element in the document.
el('#foo')
el('.bar')
This is admittedly a bit crude but should handle the most common cases. For cases which fall outside of this you can just use document.querySelector
directly and provide the element as the argument.
el(document.querySelector('some query'))
String
arguments provided as children
are appended as text nodes
el(h1, 'hello world')
<h1 class="h1">hello world</h1>
Element
arguments are just appended directly
el(h1, document.createElement('div'))
<h1 class="h1"><div></div></h1>
Since el
itself returns elements, this allows nesting of el
calls to declaratively create the DOM
el(document.body,
el('main',
el('h1', 'Title Text'),
el('p', 'Paragraph text'),
)
)
<html>
<body>
<h1 class="h1">Title Text</h1>
<p class="p">Paragraph text</p>
</body>
</html>
Function
arguments are run in the context of the parent. This allows arbitrary manipulation of the parent such as attaching listeners, setting styles, etc
el('h1', function() {
this.id = 'foo'
this.onClick = () => console.log('clicked!')
this.style.color = 'red'
})
<h1 class="h1" id="foo"></h1>
The parent is also provided as the first argument to the function when it is called. This allows arrow functions to work
el('h1', x => {
x.id = 'foo'
x.onClick = () => console.log('clicked!')
x.style.color = 'red'
})
<h1 class="h1" id="foo"></h1>
If the function returns a value, that value is appended as a child
el('h1',
x => {
return 'some text'
},
x => ' more text'
)
<h1 class="h1">some text more text</h1>
The attr(attribute, value)
function is provided as a shorthand for
$ => { $.setAttribute(attribute, value) }
This allows easy setting of attributes like this
el('h1', attr('id', 'foo'))
<h1 class="h1" id="foo"></h1>
Similarly the bind(reactor, value)
function is provided as a shorthand for
$ => {
$.oninput = () => { reactor[key] = $.value }
return new Observer(() => { $.value = reactor[key] })
}
This allows for easy 2-way binding for input fields and Reactor
objects
const rx = new Reactor({ name: 'foo' })
el('input', bind(rx, 'name'))
Array
arguments are flattened and its elements recursively appended
el('h1',[
'some text',
document.createElement('div'),
x => 'boop'
])
<h1 class="h1">some text<div></div>boop</h1>
Promise
arguments create a comment placeholder.
let somePromise = new Promise()
el('h1', somePromise)
<h1 class="h1"></h1>
When the promise resolves this placeholder is replaced with the resolved value
somePromise.resolve('resolved!')
<h1 class="h1">resolved!</h1>
Observer
functions from Elementary are handled very similarly to functions. They are executed in the context of the parent. However they also leave a set of comments bookmarking the children they produce.
const rx = new Reactor({ name: 'foo' })
el('h1', ob(() => rx.name ))
<h1 class="h1">
foo
</h1>
When the observer is retriggered, everything between the bookmarks is removed and replaced with the new output
rx.name = 'bar'
<h1 class="h1">
bar
</h1>
Read below for more details on how observers work
Note: for observers attached as children via el
they are deactivated automatically when their parent is not attached to the document. This should not affect most use cases since the observer is reactivated when the parent is reattached to the document. But note that unattached elements just being held in memory will not be changing.
Reactors
A Reactor
is an object wrapper which automatically tracks Observer
functions that read its properties and notifies the observers when those properties are updated.
You create a new reactor by just calling its constructor.
const reactor = new Reactor()
You can also wrap an existing object with a reactor by passing it to the constructor. Changes to the reactor are passed through to the underlying object.
const reactor = new Reactor({
foo: "bar"
})
Reactors behave mostly like plain javascript objects.
const reactor = new Reactor({
foo: "bar"
})
reactor.foo
reactor.cow = "moo"
Object.defineProperty(reactor, "milk", {
get() { return "chocolate" }
})
reactor.milk
delete reactor.foo
reactor.foo
The key difference of Reactor
objects is that they track when one of their properties is read by an Observer
function and will notify that observer when the property is updated.
const reactor = new Reactor({ foo: "bar" })
new Observer(() => {
console.log("foo is ", reactor.foo)
})()
reactor.foo = "moo"
Object.defineProperty(reactor, "foo", {
get() { return "meow" }
})
delete reactor.foo
Tracking is property specific so observers will not trigger if a different property is updated
const reactor = new Reactor({
foo: "bar",
moo: "mar"
})
new Observer(() => {
console.log("foo tracker is now", reactor.foo)
})()
new Observer(() => {
console.log("moo tracker is now", reactor.moo)
})()
reactor.foo = "bar2"
reactor.moo = "mar2"
reactor.goo = "goop"
If reading a reactor's property also returns an object, that object is recursively also wrapped in a reactor before being returned. This allows observers to tracks dependencies in nested objects easily.
const reactor = new Reactor({
outer: {
inner: "cake"
}
})
new Observer(() => {
console.log("inner value is ", reactor.outer.inner)
})()
Reactors are implemented using Proxy objects. This means reactors created from scratch typecheck as Reactors, but Reactors created from an existing object typecheck as the original object.
const baseReactor = new Reactor()
baseReactor instanceof Reactor
const mapReactor = new Reactor(new Map())
mapReactor instanceof Reactor
mapReactor instanceof Map
This also has implications for native objects or objects which use private properties. Since proxies can't access native or private properties, some methods will fail. To get around this, we provide the shuck
function which returns a reactor's internal object.
const mapReactor = new Reactor(new Map())
Map.prototype.keys.apply(mapReactor)
Map.prototype.keys.apply(shuck(mapReactor))
Observers
An Observer
is like a normal function that you can define and call. When an Observer
reads from a Reactor
it automatically tracks that dependency, and when that reactor's property is updated it automatically triggers the observer again.
Observer
functions are created by passing a function to its constructor.
const observer = new Observer(() => {
console.log("hello world")
})
observer()
For brevity observers can also be created and instantly executed like this
new Observer(() => {
console.log("hello world")
})()
For further simplicity the shorthand ob
is also provided that is equivalent to new Observer
ob(() => {
console.log("hello world")
})()
When an Observer
reads a Reactor
property it gets saved as a dependent. When that property is updated it notifies the observer which reruns its function. This happens automatically without any need to manually declare dependencies.
const reactor = new Reactor()
new Observer(() => {
console.log("reactor.foo is ", reactor.foo)
})()
reactor.foo = "bar"
An observer's dependencies are dynamically determined. Only the dependencies actually read in the last execution of an observer can trigger it again. This means that Reactor reads that are only conditionally used will not trigger the observer unnecessarily.
const reactor = new Reactor({
a: true,
b: "bee",
c: "cee"
})
new Observer(() => {
if (reactor.a) {
console.log("reactor.b is ", reactor.b)
} else {
console.log("reactor.c is ", reactor.c)
}
})()
reactor.b = "boop"
reactor.c = "cat"
reactor.a = false
reactor.b = "blue"
reactor.c = "cheese"
An observer's results are themselves observable via either the value
property, or by triggering the observer via observer()
and using the return value. This allows you to chain observers together.
const reactor = new Reactor({ foo: 'bar' })
const capitalizer = new Observer(() => {
return reactor.foo.toUpperCase()
})()
const printer = new Observer(() => {
console.log(capitalizer.value)
})()
reactor.foo = 'baz'
This works too:
const reactor = new Reactor({ foo: 'bar' })
const capitalizer = new Observer(() => {
return reactor.foo.toUpperCase()
})
const printer = new Observer(() => {
console.log(capitalizer())
})()
reactor.foo = 'baz'
You can stop an observer by just calling stop()
on the returned observer object. This clears any existing dependencies and prevents triggering. You can restart the observer by just calling start()
. Starting is idempotent so calling start()
on an already running observer will have no effect.
const reactor = new Reactor()
const observer = new Observer(() => {
console.log(reactor.foo)
})()
reactor.foo = "bar"
observer.stop()
reactor.foo = "cheese"
observer.start()
observer.start()
observer.start()
observer.start()
reactor.foo = "moo"
For convenience, you can call an observer to execute like a normal function. This works regardless of whether the observer is stopped. Doing so starts the observer up again.
const reactor = new Reactor({ foo: "hello" })
const observer = new Observer(() => {
console.log(reactor.foo)
})()
reactor.foo = "hi"
observer()
observer.stop()
reactor.foo = "hola"
observer()
Like normal functions, observers can expect and be called with arguments. They remember the arguments from the last time they were called and reuse them when automatically triggered.
const parameterizedObserver = new Observer((arg1, arg2) => {
console.log(reactor.foo + arg1 + arg2)
})
parameterizedObserver('beep', 'bop')
reactor.foo = 'bla'
Observers can also use and remember the last this
context. Note that just like normal functions, for the this
context to be bound to the holding object, it needs to be defined with the traditional function
keyboard instead of es6 arrow functions.
const holdingObject = {
name: 'Mario',
greet: new Observer(function () {
console.log("Hello " + reactor.foo + " itsa me " + this.name)
})
}
holdingObject.greet()
reactor.foo = 'bonk'
holdingObject.name = 'Luigi'
Hide
Sometimes you might want to read from a reactor without becoming dependent on it. A common case for this is when using array modification methods. These often also read from the array in order to do the modification.
const taskList = new Reactor(["a", "b", "c", "d"])
new Observer(() => {
console.log(taskList.pop())
})()
In these cases you can use "hide" to shield a block of code from creating dependencies. It takes a function and any reactor properties read inside that function will not be set as dependencies. hide
also passes through the return value of its function for syntactic simplicity.
const taskList = new Reactor(["a", "b", "c", "d"])
new Observer(() => {
console.log(
hide(() => taskList.pop())
)
})()
taskList.push("e")
Note that only the reads inside the hide block are shielded from creating dependencies. The rest of the observe block still creates dependencies as normal.
Overrides
If you need to access the raw function the observer is wrapping you do so with the execute
property.
const myFunction = () => {}
const observer = new Observer(myFunction)
myFunction === observer.execute
By setting this property you can change an observers internal logic. Doing so clears dependencies and retriggers the observer. Note that the previous this
and arguments contexts will stay.
const reactor = new Reactor({ foo: "bar" })
let observerToBeOverriden = new Observer((arg) => {
console.log(reactor.foo, 'and', arg)
})
observerToBeOverriden('blap')
reactor.foo = "moo"
observerToBeOverriden.execute = (arg) => {
console.log("I am saying", arg, reactor.foo)
}
reactor.foo = "blep"
Batching
One problem with automatic watchers is that you might end up with multiple repeated triggering when you're updating a whole lot of information all at once. The following code shows an example where you want to update multiple properties, but each property update prematurely triggers the observer since you are not done updating yet.
const person = new Reactor({
firstName: "Anakin",
lastName: "Skywalker",
faction: "Jedi",
rank: "Knight"
})
const observer = new Observer(() => {
console.log(
"I am " +
person.firstName +
" " +
person.lastName +
", " +
person.faction +
" " +
person.rank
)
})()
person.firstName = "Darth"
person.lastName = "Vader"
person.faction = "Sith"
person.rank = "Lord"
The batch
function is provided to allow you to batch multiple updates together and only trigger the appropriate observers once at the end of the batch block. So the last part of the previous example can be turned into:
batch(() => {
person.firstName = "Darth"
person.lastName = "Vader"
person.faction = "Sith"
person.rank = "Lord"
})
This is useful when you are making multiple data updates and want to avoid showing an "incomplete" view of the data to observers.
Note that only the observer triggering is postponed till the end. The actual reactor propertes are updated in place as expected. This means that you can have other logic with read-what-you-write semantics within the observer block working just fine.
Summary
import {
el, attr, bind, ob,
Reactor, Observer, hide, batch, shuck
} from '@fynyky/elementary'
el('h1')
el('notAValidTag')
el('notATag header body h1')
el('.foo')
el('#foo')
let aDiv = document.createElement('div')
el(aDiv)
el('h1', 'foo')
el('h1', aDiv)
el('h1', function(){this.id = 'foo'})
el('h1', x => { x.id = 'foo' })
el('h1', () => "return value")
let aPromise = new Promise()
el('h1', aPromise)
aPromise.resolve('resolved!')
let rx = new Reactor({ foo: 'foo' })
let reactiveEl = el('h1', ob(() => rx.foo))
document.body.appendChild(reactiveEl)
rx.foo = 'bar'
el('h1', ['foo', 'bar', 'qux'])
el('h1', attr('id', 'foo'))
el('h1', self => self.setAttribute('id', 'foo'))
el('h1', bind(rx, 'foo'))
el('h1', self => {
self.oninput = () => { rx['foo'] = self.value }
return new Observer(() => { self.value = rx['foo'] })
})
ob(function(){})
new Observer(function(){})
const reactor = new Reactor({ foo: 'bar' })
const observer = new Observer(() => {
const result = 'reactor.foo is ' + reactor.foo
console.log(result)
return result
})
observer()
reactor.foo = 'baz'
observer.stop()
reactor.foo = 'qux'
observer.start()
observer.start()
observer()
const trailingObserver = new Observer(() => {
const result = 'Did you hear: ' + observer.value
console.log(result)
})
trailingObserver()
reactor.foo = 'blorp'
const parameterizedObserver = new Observer((arg1, arg2) => {
console.log(reactor.foo + arg1 + arg2)
})
parameterizedObserver('beep', 'bop')
reactor.foo = 'bla'
const holdingObject = {
name: 'Mario',
greet: new Observer(function () {
console.log("Hello " + reactor.foo + " itsa me " + this.name)
})
}
holdingObject.greet()
reactor.foo = 'bonk'
holdingObject.name = 'Luigi'
reactor.ticker = 1
reactor.names = ["Alice", "Bob", "Charles", "David"]
const partialObserver = new Observer(() => {
if (reactor.ticker) {
const next = hide(() => reactor.names.pop())
console.log("next ", next)
}
})
partialObserver()
reactor.ticker = 2
reactor.names.push("Elsie")
const person = new Reactor({
firstName: 'Clark',
lastName: 'Kent'
})
new Observer(() => {
console.log('Look its ' + person.firstName + ' ' + person.lastName)
})()
batch(() => {
person.firstName = "Bruce"
person.lastName = "Wayne"
})
const mapReactor = new Reactor(new Map())
Map.prototype.keys.call(mapReactor)
Map.prototype.keys.call(shuck(mapReactor))
Development & Testing
Tests are stored in test.js
to be run using Mocha.
Run npm install
to install the the dev dependencies.
To run the tests run npm test
.
To run the tests in a browser environment run npm run browserTest
and open the test page.