Arrow-js

Arrow-js

Javascript template literals are a very powerful tool. The little tick allows for multi-line strings, embedded expressions and tagged templates. Tagged templates? Yes, the little magic that allows you to write:

await sql`SELECT email, name, prefs FROM user WHERE id=${user.id}`

or

html`hello ${() => user.name}`

and when the user.name changes the html function re-renders!

The sql example is using the @neondatabase/serverless on a bun.sh server. The html example is from arrow-js, a small (~2k) client side library that does reactivity and templates.

Breaking Down the Example

Let's break down that second example.

  1. ${} means evaluate this
  2. ()=> this is a function
  3. user.name this is reactive state

By encapsulating the state in a function, it is possible to have a re-evaluation point. The library can see what state is being accessed and the point in the dom that the function is rendering. So if you just want to output data:

html`${user.name}`

Without the function wrapper, no record exists of its usage, and so no re-evaluation.

Why Arrow-js?

If you look at the evolution of javascript from small client interactivity, to vast server fields of libraries and npm — we've landed with a host of client and server libraries, a type safe variant, and so many frameworks. All to make this small little language the world shaker that it is today. We have to develop through javascript — it is literally on every desktop and in every hand! That said — all this weight of precident needs tree shaking. As the language evolves the frameworks either change or get replaced. I recently wrote an article about vanjs. I had some issues with rendering lists, so I started looking again. That was when I found arrow-js.

Arrow-js provides three functions: reactive, watch and html.

Reactive

Reactive allows you to wrap state — state that will record access and notify.

import { reactive } from '@arrow-js/core'

const state = reactive({
  status: `connecting`,
  error: null,
  profile: null,
  broadcast: null,
})

This is the state for my WebSocket. It will hold the connection status and the user's profile and the broadcast messages to be handled by components.

const chat = reactive({
  transcript:{}
})

watch( () => state.broadcast, (value)=>{
  if (value?.channel == 'chat') {
    value.payload.created = new Date(value.payload.id)
    this.chat.transcript[value.payload.id] = value.payload
  }
})

Then we can display our transcript with an html template.

html`<ul class="transcript">
  ${() => Object.values(this.chat.transcript)
    .sort((a, b) => a.id - b.id)
    .map((item) => {
      return html`<li class="${speaker_class(item)}">
        ${item.content}<br />
        <span class="speaker">${speaker_label(item)}</span>
      </li>`.key(item.id)
    })}
</ul>`

Why speaker_class and speaker_label — that provides the colour of the bubble and the label — you know who you are! So here I have an evaluation for ul with an embedded evaluation for li. Both return html functions, but the li is followed by a call to key(), which enables DOM updates to be smaller, more precise. The most extraordinary thing is, that it's just javascript.

Three Verbs

That's your lot — three verbs: reactive, watch and html. Though each may seem simple, together they represent a truely reactive library. If you get stuck, don't forget to make it a function ()=>. If you have no idea what I'm saying, go have a play and find out.

Playground Implementation

My playground server looks like this:

import asyncio
import importlib.resources
import logging
from typing import Awaitable, Optional, Union

from tornado.options import parse_command_line
from tornado.web import Application, RequestHandler
from tornado.websocket import WebSocketHandler

class MainHandler(RequestHandler):
    def get(self):
        self.render("index.html")

class EchoWs(WebSocketHandler):
    clients = set()

    def open(self, *args: str, **kwargs: str) -> Optional[Awaitable[None]]:
        self.clients.add(self)

    def on_close(self) -> None:
        self.clients.remove(self)

    def on_message(self, message: Union[str, bytes]) -> Optional[Awaitable[None]]:
        for client in self.clients:
            client.write_message(message)

def make_app():
    static_path = importlib.resources.files(__package__) / "static"
    return Application(
        [(r"/ws", EchoWs), (r"/", MainHandler)],
        debug=True,
        static_path=static_path
    )

async def main():
    app = make_app()
    app.listen(port := 8080)
    logging.info("view on http://localhost:%s", port)
    await asyncio.Event().wait()

if __name__ == "__main__":
    parse_command_line()
    try:
        asyncio.run(main())
    except (KeyboardInterrupt, SystemExit):
        pass

Then my html looks like this:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg" href="{{ static_url('message-circle.svg') }}" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Chatty</title>
    <link rel="stylesheet" href="{{ static_url('site.css')}}" />
    <script type="module" src="{{ static_url('site.js') }}"></script>
  </head>
  <body>
  </body>
</html>

The static_url function is for tornado to cache and respond.

Now the site.js contains a class called App, which has a connect function which creates the WebSocket. The connect and send functions both return Promises. There are four display functions, which return arrow-js html functions.

import { reactive, watch, html } from 'https://esm.sh/@arrow-js/core'

class App {
  ws = null

  state = reactive({
    status: `connecting`,
    error: null,
    profile: null,
    broadcast: null,
  })

  chat = reactive({
    transcript: {},
  })

  entry = reactive({
    speaker: 'peterb',
    spoken: '',
  })

  constructor() {
    watch(
      () => this.state.broadcast,
      (value) => {
        if (value?.channel == 'chat') {
          value.payload.created = new Date(value.payload.id)
          this.chat.transcript[value.payload.id] = value.payload
        }
      }
    )
  }

  connect() {
    return new Promise((resolve, reject) => {
      const protocol = location.protocol === 'https:' ? 'wss://' : 'ws://'
      const ws = new WebSocket(
        `${protocol}${location.hostname}:${location.port}/ws`
      )

      ws.onopen = () => {
        this.ws = ws
        this.state.status = 'connected'
        resolve(this)
      }

      ws.onerror = (err) => {
        this.set_error(err)
        if (this.ws != null) {
          reject(err)
        }
      }

      ws.onclose = () => {
        this.state.status = 'disconnected'
        this.ws = null
      }

      ws.onmessage = (evt) => (this.state.broadcast = JSON.parse(evt.data))
    })
  }

  disconnect() {
    if (this.ws) {
      this.ws.close()
    }
  }

  set_error(err) {
    this.state.error = err.message || err
  }

  send_chat(speaker, content) {
    return new Promise((resolve, reject) => {
      const message = {
        channel: 'chat',
        payload: {
          speaker: speaker,
          content: content,
          id: new Date().getTime(),
        },
      }

      try {
        this.ws.send(JSON.stringify(message))
        resolve(message)
      } catch (err) {
        reject(err)
      }
    })
  }

  display_status() {
    return html`${() => (this.state.status == 'connected' ? '🫡' : '🫥')}`
  }

  display_error() {
    return html`<div
      @click="${() => (this.state.error = null)}"
      visible="${this.state.error != null}"
    >
      ${() => this.state.error}
    </div>`
  }

  display_entry() {
    return html`<form
      @submit="${async (e) => {
        e.preventDefault()
        try {
          await this.send_chat(this.entry.speaker, this.entry.spoken)
          this.entry.spoken = ''
        } catch (err) {
          this.set_error(err)
        }
        return false
      }}"
    >
      <input
        type="text"
        @input="${(e) => (this.entry.speaker = e.target.value)}"
        value="${this.entry.speaker}"
        placeholder="your name"
        name="speaker"
        required
      />
      <input
        type="text"
        @input="${(e) => (this.entry.spoken = e.target.value)}"
        value="${() => this.entry.spoken}"
        placeholder="say something"
        name="spoken"
        required
        autofocus
      />
      <button type="submit">Say</button>
    </form>`
  }

  display_transcript() {
    const speaker_class = (item) => {
      return item.speaker == this.entry.speaker ? 'from-me' : 'from-them'
    }

    const speaker_label = (item) => {
      return item.speaker == this.entry.speaker ? '' : item.speaker
    }

    return html`${() =>
      Object.values(this.chat.transcript)
        .sort((a, b) => a.id - b.id)
        .map((item) => {
          return html`<p class="${speaker_class(item)}">
            ${item.content}<br />
            <span class="speaker">${speaker_label(item)}</span>
          </p>`.key(item.id)
        })}`
  }
}

const app = (window.app = new App())

window.addEventListener('load', async () => {
  try {
    await app.connect()
    document.body.innerHTML = ''

    html`<div class="status"></div>
      <div class="content">
        <div class="entry"></div>
        <div class="error"></div>
        <div class="transcript"></div>
      </div>`(document.body)

    await app.send_chat('foo', 'bar is a three letter word.')
  } catch (err) {
    app.set_error(err)
  }

  app.display_status()(document.querySelector('.status'))
  app.display_error()(document.querySelector('.error'))
  app.display_entry()(document.querySelector('.entry'))
  app.display_transcript()(document.querySelector('.transcript'))
})

Reflection

This is how far I got with arrow-js. It seems to do everything I could want it to. I'm sure you can see the ages of javascript — yes, I use a class and ooh, querySelector. My css is even worse! I'm overjoyed flex exists, I overuse it with relish.

Since knockout-js there have been many small reactive libraries. And they have made the world easier to live in. We used to declare our templates as hidden header elements, now they are in the javascript without a precomiler.

There are three languages that make up a web page: html, css & javascript. They each vie for who will do what. Think of how modal dialogs have moved from javascript to css and now to html. Arrow-js joins a crowded field, and it has yet to leave alpha! I would suggest it is remarkable for what it does, how it does it. It's not Vue (my preference), certainly not Angular and though a bit React-ish, it's worth an explore.

If you've tried Arrow-js let us know what you think in the comments.


In Plain English 🚀

Thank you for being a part of the In Plain English community! Before you go: