@doars/staark
v1.7.17
Published
A teeny-tiny framework for building web apps.
Readme
staark
A teeny-tiny framework for building web apps.
- Minimal amount of concepts to learn in order to get going, meaning the system becomes incredibly easy to reason with.
- Comes in at two kilobytes in size when compressed. Due to the minimal philosophy of the library and the simple concepts within the total size is tiny as well.
- Has an efficient diffing algorithm. This ensures the node tree is morphed quickly from the old to the new with minimal overhead.
- Utilises a proxy to manage the application state, the view is therefore only updated on changing the state, and can also be manipulated outside of event listeners.
To get you up and running you only need to know two functions: mount and node. There are a few more but lets go over the basics first. With mount you attach the application to the document, by providing it with a node from the document, a view function, and optionally an initial state. The view function takes in the state and outputs an abstract representation of the document using the node function. With node you create an abstract representation of a single node. The library then takes these nodes returned by the view function and creates the actual document for the browser to render.
import { mount, node as n } from '@doars/staark'
mount(
'#app',
(state) => n('div', [
n('span', state.count),
n('button', {
click: () => state.count++,
}, 'add')
]),
{ count: 0 },
)Use a key attribute to preserve a DOM element when sibling nodes are inserted, removed, or reordered. Keys must be unique among siblings and are reconciliation metadata, so they are not written to the DOM.
node('ul', items.map((item) =>
node('li', {
key: item.id,
}, item.label)
))Of course there is always more to a front-end framework to help you out. There is a memo function for using memoization to optimize time costly operations. The function takes in a view function and a state. Only if the state has changed will the view function be called again, otherwise an earlier copy still in memory will be returned.
import { memo, mount, node as n } from '@doars/staark'
const halfCount = (state) => n('span', state.count / 2)
const incrementCount = (event, state) => state.count++
mount(
'#app',
(state) => n('div', [
n('span', state.count),
n('button', {
click: incrementCount,
}, 'add'),
memo(
halfCount,
state.count % 3 === 0,
),
]),
{ count: 0 },
)In the example above the text will always be shown. However it only updates when the number changes from divisible by three to not divisible by three, and vice versa. This is because the value of the second argument will change from true to false. The second argument can of course be anything even a deeply nested object. But do note that the function is not defined within the view function, instead it is defined earlier outside of it. This allows it to have a unique and static identifier as well as be pure separately from the rest of the application.
Also note how the click listener has been extracted outside of the view function. This can be done because the first parameter is the event like in vanilla JavaScript and the second parameter is the state. Extracting the listener is beneficial because Staark can reuse the same listener between renders. A function defined inside the view has a new identity on every render: (() => {}) === (() => {}) is always false, just like {} === {}.
In addition to the functions provided it is good to know that some attribute properties are treated differently. As you have probably noticed in the examples above, a function gets added as a listener, making reactivity easy to handle. The class and style attributes can also be supplied as objects.
const objectAttributes = {
class: {
'btn-link': false,
'nav-link': true,
active: true,
},
style: {
color: 'red',
textDecoration: 'underline',
},
} // Applies class="nav-link active" and the corresponding DOM styles.
const arrayAttributes = {
class: [
'nav-link',
'active',
],
} // Applies class="nav-link active".Camel-case style keys are assigned through the corresponding DOM style properties. Style names containing hyphens are applied with style.setProperty. Class names can be expressed using either an array or an object whose truthy keys are included.
All the functionality above is part of the base library there is an expanded version which has a bit more non-essential functionality to make development simpler. The first of these being the conditional and match functions. These allow you to easily add branching into rendering the node tree. The conditional function takes in a statement, if the statement is true the first parameter will be returned, otherwise the second parameter will be returned. For the match function the first parameter is the key of one of the entries into the second parameter, the matching one will be returned by the function.
import { conditional, match, mount, node as n } from '@doars/staark'
mount(
'#app',
(state) => n('div', [
...conditional(
state.count === 0,
'Count is zero',
'Count is non-zero',
),
...match('option-' + state.count, {
'option-0': 'Count is zero',
'option-1': 'Count is one',
'option-2': 'Count is two',
}),
]),
{ count: 0 },
)The returned results are always an array, hence the spread operator being used. The truthy and falsy values passed to conditional, as well as the properties on match's lookup object, may be functions. These are evaluated only when selected, reducing the number of abstract nodes created for unused branches.
Another useful tool is the factory object, it can make creating nodes a little simpler. You can deconstruct it to create node function which don't need the node type as the first argument.
import { factory, mount } from '@doars/staark'
const { button, div, span } = factory
mount(
'#app',
(state) => div([
span(state.count),
button({
click: () => state.count++,
}, 'add')
]),
{ count: 0 },
)Then there is the nde function. It allows you to create a node using a query selector instead of a node type and attributes object.
import { mount, nde } from '@doars/staark'
mount(
'#app',
() => nde('a.nav-link.active[href="/next-page/"][target=_blank]', 'Next page'),
)As you can probably guess there is also a fctory object, which is a combination of the factory object and nde function. You can deconstruct it to create node functions where you do not have to specify the node type and can write the attributes as a query selector.
import { fctory, mount } from '@doars/staark'
const { a } = fctory
mount(
'#app',
() => a('.nav-link.active[href="/next-page/"][target=_blank]', 'Next page'),
)Now we come to the two more functions needed to stop or update the application. These aren't imported, but returned by the mount function. An update and unmount function. These can be deconstructed from a list, where the first value is the update and the second the unmount function. As the names suggest, with the update function a re-rendering can be forced, and with the unmount function the application can be terminated after which it will be removed from the page.
import { mount, node as n } from '@doars/staark'
const [update, unmount] = mount(
'#app',
() => n('div'),
)
await update()
unmount()Last but not least there is the second way to provide and mutate the state. This will be primarily done inside the app, but sometimes data from outside needs to be passed into the app. This can be done using the third argument and third value returned by the mount function. The third argument can be a recursive proxy in which case it is up to you to invoke the update function once a re-render needs to be done.
import { mount, node as n } from '@doars/staark'
const proxy = new Proxy({
count: 1,
}, {})
const [update, unmount] = mount(
'#app',
(state) => n('div', state.count),
proxy,
)Another method to influence the state outside the app is using the third value returned, which is the state proxy used by the app. This is the same proxy that the render function receives and as such can be manipulated similarly. Having the state available outside of the view function allows you to do some nifty things. For instance in the example below the viewport width is used inside the application, and updated when the window changes size.
import { mount, node as n } from '@doars/staark'
const [update, unmount, state] = mount(
'#app',
(state) => n('div', state.width),
{ width: window.innerWidth, },
)
window.addEventListener('resize', () => {
state.width = window.innerWidth
})Then there is a fourth parameter for the mount function. This can be the abstract node tree corresponding to existing HTML, typically rendered on the server and provided to the client. See staark isomorphic for more information. If the state or existing abstract node tree is a string, it is automatically parsed as JSON.
And well, that is everything you need to know about staark in order to be an expert at using it!
Installation
Via NPM
npm install @doars/staarkIIFE build via a CDN
<!-- Base bundle -->
<script src="https://cdn.jsdelivr.net/npm/@doars/staark@1/dst/staark.base.iife.js"></script>
<!-- Base bundle minified -->
<script src="https://cdn.jsdelivr.net/npm/@doars/staark@1/dst/staark.base.iife.min.js"></script>
<!-- Full bundle -->
<script src="https://cdn.jsdelivr.net/npm/@doars/staark@1/dst/staark.iife.js"></script>
<!-- Full bundle minified -->
<script src="https://cdn.jsdelivr.net/npm/@doars/staark@1/dst/staark.iife.min.js"></script>ESM build via a CDN
// Base bundle.
import { mount, node as n } from 'https://cdn.jsdelivr.net/npm/@doars/staark@1/dst/staark.base.js'
// Base bundle minified.
import { mount, node as n } from 'https://cdn.jsdelivr.net/npm/@doars/staark@1/dst/staark.base.min.js'
// Full bundle.
import { mount, node as n } from 'https://cdn.jsdelivr.net/npm/@doars/staark@1/dst/staark.js'
// Full bundle minified.
import { mount, node as n } from 'https://cdn.jsdelivr.net/npm/@doars/staark@1/dst/staark.min.js'Known issues
- A
memofunction inside amemofunction isn't going to see the benefits of memoization. After an update any memoized calls that haven't been used will be cleared from memory. This means that if the outermemofunction was used the inner call's data will be culled, as a result during the next update when it might be needed the data will have already been lost. It is therefore not recommended to usememofunction inside of one another. The library will of course still handle everything as intended, there just won't the performance boost you might be hoping for.
Artificial intelligence
To make prompting easier, use the contents of llms.txt to give a model the compact context required to build apps with Staark.
