After giving ParenScript another chance I became hopeful of the possibilities for front-end development in Common Lisp. It is not the language it is the tooling, but Lisp is a language that lets you develop your tooling faster. Could I close the gap to the ClojureScript+Reagent+Re-frame ecosystem? Well, optimization is my favorite procrastination, so I might as well give it a try.

From that last post, I’ll take the function dom-el as a minimalist version of react+jsx or reagent+hiccup. It is nowhere near a fair comparison to those projects, but it is just enough utility to transform a nested tree of data into a tree of elements in the DOM. That is a good start. Fixing speed issues is a problem I’ll address when I get there. Until now, on a PC dumping the old DOM tree and rendering a new one is fast enough. It might not be true for mobile device or battery efficient.

What to copy from re-frame?

Re-frame is a framework to build user interface on the web. To me it is the correct architecture to manage state, follow events and then react rendering a new view. All I need to copy is the event loop to see how far I get with it. Choosing to render a view directly from the app database, the is no need for subscriptions. With that choice I can create the loop with two functions, the event listener and the caller, here named send

 1(defvar *app-db*)
 2
 3(defun mount (element view)
 4  ((chain element add-event-listener)
 5   "prelude:main"
 6   (lambda (event)
 7     (setf (inner-html element) "")
 8     ((@ element append-child) (dom-el (funcall view *app-db*))))))
 9
10(defun send (name value &optional (elem document))
11  (let ((event (new (-custom-event (+ "prelude:" name)
12                                   (create bubbles t
13                                           cancelable t
14                                           detail value)))))
15    (chain elem (dispatch-event event))))

Next are functions to register events. Events are the only allowed way to modify the application database. An event, gets registered using reg-event, and it can modify the database and/or trigger some other side effect. reg-event-db registers events which only modify the database is a convenience version of reg-event. Side effect events go into a separate registry use a different registration function reg-fx.

 1(defvar *app-events* (create))
 2
 3(defun reg-event (name fn)
 4  (setf (getprop *app-events* name) fn))
 5
 6(defun reg-event-db (name fn)
 7  (setf (getprop *app-events* name)
 8        (lambda (obj &rest args)
 9          (apply fn (getprop obj 'db) args)
10          obj)))
11
12(defvar *app-effects* (create))
13
14(defun reg-fx (name fn)
15  (setf (getprop *app-effects* name) fn))

What brings everything together is the event trigger dispatch.

 1(defun dispatch (name &rest args)
 2  (let ((handler (getprop *app-events* name)))
 3    (logger :log "enter dispatch for" name "with args" args)
 4    (if handler
 5        (with-slots (db fx)
 6            (apply handler (create :db *app-db* :fx (list)) args)
 7          (logger :log "Handled" name "changing db to" db "and remaining fx" fx)
 8          (for-of (action fx)
 9            (destructuring-bind (task . args) action
10              (logger :log "FX " task)
11              (apply (getprop *app-effects* task) args)))
12          (send "main" "DB update"  (getprop db "__mounted-element")))
13        (logger :error "unknown handler to dispatch" name))))

I have included some logging on the way for debugging purposes.

This is to my own surprise all you really need to get far in user interface design. Next lets build a demo.

When in doubt, make a to do list

That is the most boring example, yet any framework must cover that example.

This app has no persistence. The application database is ephemeral running on the current page memory. All the contents of the database is the to do list, which is a simple list of JS key-value pair objects, with only two entries the task and if done.

Let’s start generating the views directly from the database of to do lists. I only need to generate the list data structures that represent the DOM elements. It is a lot of boilerplate to use list to define every list, but the quasi-quote doesn’t really work on ParenScript. I style all elements using tailwindcss

 1(defun todo-item (item idx)
 2  (with-slots (task done) item
 3    (list "li"
 4          (list "div.hover:underline.hover:bg-gray-200.cursor-pointer.inline"
 5                (create onclick (lambda (e)
 6                                  (dispatch "todo-state" idx (not done))))
 7                (list "input.mx-2.size-4"
 8                      (create type "checkbox" checked done))
 9                task)
10          (list "i.fas.fa-xmark.text-red-600.p-2.text-lg.cursor-pointer"
11                (create onclick (lambda (e) (dispatch "todo-delete" idx)))))))
12
13(defun todo-list (todos)
14  (list "div.my-4"
15        (list "h2.text-3xl" "Todo lists")
16        (append
17         (list "ul.py-2")
18         ((chain todos map) #'todo-item))
19        (list "input.border.p-2"
20              (create type "text"))
21        (->> (lambda (e)
22               (dispatch "todo-new" (@ e target previous-sibling value)))
23             (create type "submit" onclick)
24             (list "input.border.bg-blue-200.p-2"))))

Pay attention to lines 6, 11, 22, those are the controls in the app. They dispatch the events to toggle the state, delete the item and create a new to do respectively. Next are the definitions of those states and how the modify the database db. As a good habit, I name-spaced the events with the todo- prefix, and the database holds the to do list under the key todos.

 1(reg-event-db
 2 "todo-state"
 3 (lambda (db index state)
 4   (setf (getprop db 'todos index 'done) state)
 5   db))
 6
 7(reg-event-db
 8 "todo-delete"
 9 (lambda (db index)
10   (chain db todos (splice index 1))
11   db))
12
13(reg-event-db
14 "todo-new"
15 (lambda (db value)
16   (let ((trimmed (chain value (trim))))
17     (when trimmed
18       ((chain db todos push)
19        (create task trimmed done f)))
20     db)))

In Clojure(Script) you work with immutable data. We are however on mutable JavaScript, the database changes on each of these events by mutation not by an atomic update. That is a sad state of affairs, but it is fine because we still keep the discipline of only changing the database using the events.

The last thing to do is to load the app. Have fun with it.

1(add-event-listener
2 "load"
3 (lambda (event)
4   (let ((el (chain document (query-selector "#todo-app"))))
5     (mount el (lambda (app-db) (todo-list (getprop app-db 'todos))))
6     (setf *app-db* (create todos (list) "__mounted-element" el))
7     (send "main" "Initial render" el)))
8 (create once true))

I’m again amazed how simple it was. If you are curios about the JavaScript code, you’ll have to read the source code of this page.

If you want to use side effects you register an event that pushes the side effect instruction to the list under the fx key. Refer to the dispatch function. Then you register the side effect itself using reg-fx. That is an exercise left to the reader.