Skip to content

tatomyr/purity

Repository files navigation

Purity. Declarative State & DOM Manager

check deploy

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.

Usage

Basic Syntax

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!

Nested Components

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).

Event Binding

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.

Async Flow

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.

Virtual DOM

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 ids 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
Loading

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.

Tips

  • 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).

Credits

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.

Examples of usage

Please find the examples here

If you want to run them locally, see the contributing guide.

Playground

Feel free to experiment in the Playground.

Miscellaneous

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.