Quiescent / Light Table walkthrough
This walkthrough is based on a presentation that I gave at the Amsterdam Clojure Meetup in March 2014.
The walkthrough uses Light Table and the Quiescent TodoMVC to demonstrate what it feels like to develop a web-UI with Quiescent, a lightweight ClojureScript abstraction over ReactJS.
To follow along you need to install leiningen, git and Light Table.
Open the TodoMVC application in your browser
- Clone this github repo
- Go to the repo with
cd todomvc
- Run
lein cljsbuild once
- Open
index.html
with a browser - Open the browser development window (shift-ctrl-i) and select the console tab
After each transaction the application logs the transaction plus the application state. So, just use the application and watch the log-messages in the browser-console. This should give you an idea how your actions in the UI relate to transactions and state-changes in the application.
Open the TodoMVC application in a Light Table browser-tab
-
Leave your browser open and start Light Table.
-
Open a browser-tab with
ctrl-space
-> Add connectionChoose a client type -> Browser
The browser-tab is displaying
about:blank
as can been seen at the bottom left. -
Return to the browser to copy the URL (eg; on my desktop it's
file:///home/walter/todomvc/index.html
) -
Paste the URL in Light Table to replace
about:blank
and press enter.
If you want you can open a browser console in Light Table with
ctrl-space
-> Console: Toggle console.
Open the Clojurescript code in a Light Table tab
You have now opened the todo-application in a Light Table browser-tab. Next we will connect this application to the corresponding Clojurescript code. First let's open the code in a new Light Table tab.
ctrl-space
-> Workspace: add folder- Select the folder that you cloned from github
(eg; on my desktop it's
/home/walter/todomvc
) ctrl-space
-> Workspace: Toggle workspace tree this will show/hide the workspace tree- In the workspace tree (sidebar on the left) open
application.cljs
which you will find intodomvc/src/todomvc/application.cljs
You now have two tabs in Light Table; Quiescent TodoMVC
and application.cljs
.
Let's put them side-by-side to get a better overview.
ctrl-space
-> Tabset: Add a tabset- Drag one of the two tabs to the new tabset
- Hide the workspace tree with
ctrl-space
-> Workspace: Toggle workspace tree
No further action is needed. The code in application.cljs
is now connected to
the todo-application in the browser-tab.
Let's see what that brings us.
Inspecting the application state
Go to the end of application.cljs
. The last line reads (def app-hook app)
. This
makes the application available outside the main
function so we can use
this app-hook
to sniff around in the application-state.
To evaluate expressions in Light Table you have to position the cursor right
after the expression and press ctrl-enter
.
For example, type @(:state app-hook)
on a newline at the end of
application.cljs
and evaluate with ctrl-enter
.
This will show the application-state.
If the result of the evaluation is too big for the screen Light Table will only show the first bit. But if you click on the evaluation result Light Table will expand it.
To look at the list of todo-items evaluate
(:items @(:state app-hook))
If you get []
as an answer it means that your todo-list is empty.
Click on 'What needs to be done?' in the browser-tab and enter some
todo's. Now go back to the application.cljs
tab, put
the cursor right after (:items @(:state app-hook))
and press
ctrl-enter
again.
This allows you the see how actions in the browser-tab cause changes in the application state.
Changing the application state
If you look a few lines up in application.cljs
you will see
the expression (swap! state transact transaction)
inside the
init-updates
function.
This is the expression that processes the transactions coming in from
the UI.
We have already seen this as log-messages in the browser-console.
Since values in Clojure are immutable it is easy to run transactions without changing the application state. Let's try this by toggling the status of all items with:
(transact/main @(:state app-hook) [:toggle-all])
Depending on the state of the application this will change :all-done?
to either true
or false
. Now, evaluate the expression again.
The value of :all-done?
remains the same. This is because you are
not changing the actual state of the application. The actual state of
the application is stored in the (:state app-hook)
atom.
Let's change the actual state by evaluating this expression:
(swap! (:state app-hook) transact/main [:toggle-all])
That worked, the state has changed. And if you keep pressing
ctrl-enter
you can see the value of :all-done?
alternate between true
and false
.
Rendering the UI
But the change is not shown in the browser-tab. To see the changed state reflected in the UI you must render the application by evaluating this expression:
(render/main app-hook)
Likewise you can add an item with
(swap! (:state app-hook) transact/main [:add-item "More work"])
and show it in the browser-tab with (render/main app-hook)
.
That's fun but a bit low level. The init-updates
function creates a
go-block that will patiently wait for a transaction coming in through a
core.async
channel.
You can put a transaction on this channel with
(put! (:channel app-hook) [:add-item "More exercise"])
The browser-tab will automatically update as the transaction is processed within the go-block.
Render test
React, in combination with .requestAnimationFrame
, will avoid much
of the rendering. Let's see this in action.
To remove the current todo's from the list run:
(doseq [id (map :id (:items @(:state app-hook)))]
(put! (:channel app-hook) [:remove-item id]))
And you can populate the table with generated items like this:
(doseq [i (range 200)]
(put! (:channel app-hook) [:add-item (str "Have fun " i)]))
Now we combine the adding and removing to see if our application does indeed avoid most of the rendering.
(let [num 200]
;; remove all todo's to get a clean start
(doseq [id (map :id (:items @(:state app-hook)))]
(put! (:channel app-hook) [:remove-item id]))
;; reset :next-id to 0
(swap! (:state app-hook) assoc :next-id 0)
;; add items
(doseq [id (range num)]
(put! (:channel app-hook) [:add-item (str "Have fun " id)]))
;; remove items
(doseq [id (range num)]
(put! (:channel app-hook) [:remove-item id])))
On my machine I'm not seeing any intermediate rendering. So it seems like React is doing its job.
Rendering with Quiescent
All rendering is handled in render.cljs
.
We are going to use Light Table to jump to the definition of render/main
.
- Move the cursor to any occurance of the string
render/main
within fileapplication.cljs
ctrl-space
-> Editor: Jump to definition at cursor
This will take you to the definition of render/main
.
The keyboard shortcut for Jump to definition is ctrl-.
(control + dot).
Likewise, ctrl-,
(control + comma) takes you back to the previous position.
render/main
uses a boolean atom, render-pending?
,
in combination with .requestAnimationFrame
to make sure that the total
amount of renders will be less or equal to the browser refresh rate.
The function that is scheduled to perform the actual rendering is q/render
.
This is the top-level Quiescent function.
It takes two arguments:
(App @state channel)
will render the application UI(.getElementByIdj js/document "todoapp")
points to the DOM-element that will be controlled by React. You can find the definition oftodoapp
inindex.html
as element<section id="todoapp"></section>
.
Quiescent dom-elements
If you look at the definition of App
in render.cljs
you find several
calls to functions like d/div
, d/section
, d/input
, etc.
These are Quiescent-functions that represent dom-elements.
Open the elements-tab in your browser's development window and check for yourself
that there is a one-on-one relationship between the elements defined in App
and
the dom-elements within <section id="todoapp"></section>
.
Let's look, for example, at this expression at the end of the Footer
component:
(when (< 0 completed)
(d/button {:id "clear-completed"
:onClick #(put! channel [:clear-completed])}
(str "Clear completed (" completed ")")))
This defines a button that will only be included in the UI if the number of
completed
items is larger than zero.
If the button is clicked the [:clear-completed]
transaction is
pushed on the core.async
channel.
Again, you can check this in the elements-tab of your browser.
Add an item, mark it as completed and do inspect element on the
Clear completed (1)
button that appears in the bottom right of the UI.
This element shows up exactly were it is defined in render.cljs
,
at the end of footer
.
You will not see the :onClick
in the browser.
This is because events are handled in the React virtual dom.
For Chrome you can install React Developer Tools.
This will give you an extra Development Tool tab with React specific
information like, eg, the event handlers.
Here you can find more documentation on Quiescent dom-elements.
Quiescent components
Functions like App
and TodoList
are Quiescent components defined
with q/defcomponent
.
These act like any other Clojure function apart from two special requirements:
- They all return a Quiescent dom-element as the function result
- The first argument specifies the
state
relevant for the component
Some examples will help to clearify the second requirement.
The Header
component is called with nil
as first argument.
If you look at the definition of Header
you will see why.
Header
is not using anything from the application state
.
As a result it will only be rendered once since no further rendering is needed.
An other example is Footer
.
The first argument of Footer
is [current-filter items]
.
Both current-filter
and items
come from state
.
They are passed to Footer
in a vector because the first
argument of Footer
must contain all state
.
You could pass the complete state
to Footer
.
But Footer
does not depend on :all-done?
, so, sending the complete
state
will cause unneeded rendering for Footer
.
Here you can find more documentation on Quiescent-components.
Changing the UI
As an exercise we will change Header
such that the
text in the new-todo
input changes to "Anything more?"
if there
are unfinished todo's.
First we change App
because it needs to send all-done?
as an
argument to Header
.
In App
change this
(Header nil channel)
to this
(Header all-done? channel)
Now you have the evaluate (d/defcomponent App ...)
with ctrl-enter
.
Next we move to Header
(press ctrl-.
while the cursor is positioned
on Header
) to let it receive all-done?
as first argument.
In Header
change this
(q/defcomponent Header
"The page's header, which includes the primary input"
[_ channel]
to this
(q/defcomponent Header
"The page's header, which includes the primary input"
[all-done? channel]
We also have to change the logic for :placeholder
so we change this
:placeholder "What needs to be done?"
to this
:placeholder (if all-done?
"What needs to be done?"
"Anything more?")
Now you have to evaluate (d/defcomponent Header ...)
with ctrl-enter
.
Enter some todo's in the list to check the new placeholder functionality.
Compiling todomvc.js
Let's check this change in the browser.
- Refresh the browser
- Enter some items
Nothing has changed.
The new functionality for :placeholder
has not reached the browser.
This is because index.html
is using todomvc.js
and Light Table
does not update this file.
Let's fix this by re-compiling todomvc.js
with leiningen.
- Save the changes you made to
render.cljs
in Light Table withctrl-s
- Recompile todomvc.js with
lein cljsbuild once
- Refresh the browser
- Enter some items
You can save yourself some time during development by
running lein cljsbuild auto
.
This instructs leiningen to automatically re-compile todomvc.js
whenever
a source-file is saved.
Application logic
The application logic is contained in transact.cljs
.
As you will see contained is the right word.
transact.cljs
is completely ignorant about the UI.
It doesn't even know were the application state is stored.
As a result the code in transact.cljs
can be moved from the browser
to the JVM simply by changing the filename from transact.cljs
to transact.clj
.
This might not be impressive for the todomvc application but for a larger application with a back-end it is a major advantage for it will allow you to run integrated tests involving the back-end logic and the front-end logic.
Open transact.cljs
in a Light Table tab and go to the last line and evaluate
this expression to test the application logic:
(try-transactions
[[:add-item "bread"]
[:add-item "butter"]
[:toggle-item 10]])
Our next step is to run the same code on the JVM. This will only work if you have a JVM installed on your machine.
If you don't have a JVM installed I would advice you to skip this last step of the walkthrough because you won't see anything different.
I tried to save the file as transact.clj
from Light Table using ctrl-shift-s
.
But that does not work.
Light Table is smart enough to leave the file extension as it is.
- Go to a terminal or file manager and copy
transact.cljs
totransact.clj
- Right-click in the Light Table workspace tree and select refresh folder
- Open
transact.clj
- Evaluate
transact.clj
withctrl-shift-enter
Now we can run our test on the JVM.
Go to the last line of transact.clj
and evaluate the same expression:
(try-transactions
[[:add-item "bread"]
[:add-item "butter"]
[:toggle-item 10]])