Declarative UI library for using most of today's Javascript. It doesn't require any bundlers or using npm at all, and it fully leverages the native ECMAScript modules system.
Check out our Playground to see Purity in action.
To use Purity in a project, you have to put in your index.html a root element where your app will be mounted into, and a script tag of [type=module]
which points to the main js file:
<html>
<body>
<div id="root"></div>
<script type="module" src="./main.js">
</body>
</html>
Purity exposes two main methods to manipulate an application:
-
init
which initializes the app with a default state (application-wide) -
render
tag that wraps string templates that represent app components
Import them from the local file or a public URL, e.g.:
import {init, render} from "https://tatomyr.github.io/purity/purity.js"
Next, you init the app with some default state. This will return a bunch of methods you can use in your app:
const {mount, getState, setState} = init(defaultState)
Then you declare a component using the render
tag:
const root = () => render`
<div id="root">Hello Purity!</div>
`
Make sure that your root element has the same id
attribute as the root defined in index.html.
The first will override the latest.
Finally, you have to mount the root
to DOM:
mount(root)
That's it! The simplest possible Purity application is ready to be deployed!
As your DOM tree grows, you may want to extract some components. Since they are merely bare functions that return a string, we can embed other functions called with some arguments, that return a string:
const child = ({name}) => render`
<div>Hello ${name}!</div>
`
const parent = () => render`
<h1>Welcome page</h1>
${child({name: "Guest"})}
`
Yes, you may return several nodes from a component. They don't necessarily have to be wrapped into one (except for the root one).
We can add some interactivity by binding events:
const clickable = () => render`
<button ::click=${() => alert("Hello!")}>
Click Me
</button>
`
Please notice the double-colon syntax. The pattern is ::event-name=${<event-handler>}
.
Purity binds events to DOM asynchronously, so be careful when writing tests.
You have to use await delay(0)
before you can simulate an event after DOM gets updated.
There is also another substantial limitation to using event handlers.
Do consider each handler an isolated function that can receive nothing from the upper scopes.
For instance, the example below is wrong since we are trying to use COUNT
(which has been calculated in the component's scope) inside the click handler:
const wrongCounter = () => {
const COUNT = getState().count
return render`
<div id="root">
<pre id="count">Counter: ${COUNT}</pre>
<button
::click=${() =>
setState(() => ({count: COUNT /* Incorrect value! */ + 1}))}
>
Increment
</button>
</div>
`
}
Although the increment on click will work once, it is not guaranteed to do so every time. The event binds on the first execution, but the button doesn't get updated further, so both the event handler and its closure remain the same.
The correct example would look like this:
const correctCounter = () => {
const COUNT = getState().count
return render`
<div id="root">
<pre id="counter">Counter: ${COUNT}</pre>
<button
::click=${() =>
setState(({count}) => ({count: count /* Correct value! */ + 1}))}
>
Increment
</button>
</div>
`
}
Please notice that setState
's callback receives the current state as an argument.
One more important thing to notice is that the pre
tag has an id
attribute defined.
This allows to only update its content without re-rendering other nodes that don't have visual changes.
This helps the button
not to lose focus on each click.
See more in the Virtual DOM section.
You can implement the simplest async flow using a tiny helper (you may also import it from once.js):
const makeOnce = () => {
const calls = new Set()
return (id, query) => {
if (!calls.has(id)) {
calls.add(id)
setTimeout(query)
}
}
}
where id
is a unique identifier of the async operation and query
is an asynchronous callback function which gets executed once for the id
.
It can be used like this:
const {mount, getState, setState} = init({
spinner: false,
stargazers_count: "-",
})
const url = `https://api.github.com/repos/tatomyr/purity`
const getStargazers = async () => {
try {
setState(() => ({spinner: true}))
const {stargazers_count} = await fetch(url).then(checkResponse)
setState(() => ({stargazers_count, spinner: false}))
} catch (err) {
setState(() => ({stargazers_count: "🚫", spinner: false}))
}
}
const once = makeOnce()
const root = () => {
once(url, getStargazers)
return render`
<div id="root">
<pre id="stars">
${getState().spinner ? "⌛" : `⭐️: ${getState().stargazers_count}`}
</pre>
<button ::click=${getStargazers}>
Refetch
</button>
</div>
`
}
mount(root)
You may also check out the imperative example (or alternatively the declarative one) and the complex useAsync example for advanced cases.
Bear in mind that each changeable node should have a unique id
attribute defined on it.
This allows the DOM re-renderer to decouple changed nodes and update only them.
It has nothing to do with components, which are just functions to calculate the HTML.
You can think of your application as a tree where each tag with the id
attribute is represented by a virtual node.
The most important part of the virtual DOM is the rerenderer.
It calculates new virtual DOM and traverses through each existing virtual node.
If a new corresponding virtual node exists, and it shallowly differs from the previous one, the rerenderer replaces innerHTML
of the node and attributes of a wrapper tag.
This way, the rerenderer could preserve text inputs cursor position, scrolling progress, &c. At the same time, it allows a programmer to fully control the updating process.
DOM nodes get re-rendered depending on how id
s are placed across them.
Basically, Purity will re-render everything inside the closest common ancestor with an id
defined on it.
To get a better understanding, let's compare two applications that differ only by one id
attribute.
const noId = () => render`
<div id="root"> <!-- The entire root will be re-rendered as it's the closest `id` to the changes -->
<span>
${getState().count} <!-- The actual changes -->
</span>
<button
::click=${({count}) => setState({count: count + 1})}
>
Update
</button>
</div>
`
const withId = () => render`
<div id="root">
<span id="count"> <!-- Only this element will be re-rendered -->
${getState().count}
</span>
<button
::click=${({count}) => setState({count: count + 1})}
>
Update
</button>
</div>
`
You can see the difference in the graph below:
graph TD
subgraph State
state[$count: 0 -> 1 *]
end
subgraph withId
root2[#root] --> span2[span#count] --> count2[$count *] == rerender the nearest # ==> span2
root2 --> button2[button::click] == increment ==> state
end
subgraph noId
root[#root] --> span[span] --> count[$count *] == rerender the nearest # ==> root
root --> button[button::click] == increment ==> state
end
In the noId example, after updating the state inside the span, all the app gets re-rendered since the closest node with id
is root.
As a consequence, button loses focus.
On the other hand, in the withId example, the only thing going to be re-rendered is text inside span#count.
-
Use uncontrolled text inputs and put them wisely, so they won't be re-rendered when the input value gets changed. Form elements like checkboxes and selects could be used either in a controlled or uncontrolled way.
-
Wrap every component you want to be re-rendered independently with a tag with a unique
id
. -
Do not rely on any constants declared in a component's scope inside event handlers. Each event handler should be considered completely isolated from the upper scope. The reason is that the Virtual DOM doesn't take into account any changes in event handlers. Albeit you do may use a data-specific
id
on the tag to change this, it is not recommended due to performance reasons. See the example for more context. -
Root component must have the same
id
as the HTML element you want to mount the component to. (Depends on the algorithm we're using for mounting.) -
A component's local state management is considered a secondary feature. Therefore it's not a part of the library. However, it could possibly be implemented using the rerender method which is returned from the init function (see example).
-
The library doesn't sanitize your inputs. Please do it by yourself or use the
sanitize.js
module. -
Due to its asynchronous nature, Purity requires special testing for applications that use it. Make sure you make delay(0) after the DOM has changed (see examples in purity.test.ts).
This library is heavily inspired by project innerself. And obviously, I was thinking of React.
The decision to use bare ES modules appears to be the consequence of listening to the brilliant Ryan Dahl's talk on Deno.
- Counter
- Simple todo
- Asynchronous todo
- Colored input
- Stateful counters
- ToDo application
- Async search
- Multiple Applications on the same page
Please find the examples here
If you want to run them locally, see the contributing guide.
Feel free to experiment in the Playground.
The library also includes a handful of algorithms from different sources, exported as ES modules to use with Purity or without.
The most important ones are router
and async
which could help with navigation and performing asynchronous operations respectively.