Skip to content
Marcelo Nomoto edited this page Nov 15, 2023 · 17 revisions

Hoplon Overview

Table of Contents

Introduction

What's Hoplon?

Hoplon is a ClojureScript library for developing Single-page Applications.

In particular, Hoplon applications are usually made up of two things: components and cells

  1. components are ClojureScript functions that return other components or DOM nodes and are composed to form user interfaces.
    • control how the application looks at any one time
    • delivered by the hoplon/hoplon library
  2. cells are ClojureScript reference types that are composed to perform transactional state transitions in response to user or server-sent events.
    • control how the application looks and behaves over time
    • delivered by the hoplon/javelin library

Using this document

This document is intended to convey enough information about Hoplon for you to make a decision about whether or not to use it, and to get you started if you do.

If you are assessing Hoplon, in addition to reading this document you may consider setting your computer up for Hoplon development. It takes about 10 minutes.

Otherwise, you can skip ahead to Components to learn more about Hoplon without setting up a development environment.

Basic development setup

Follow the instructions below to set up your computer to develop Hoplon applications. Windows, MacOs and Linux will all work fine.

Install clojure

https://clojure.org/guides/install_clojure

Install nodejs

https://nodejs.org/en/download

Create a scratch project for experimenting

Install deps-new if you haven't already:

clojure -Ttools install-latest :lib io.github.seancorfield/deps-new :as new

And then generate a starter project with:

clojure -Sdeps '{:deps {io.github.hoplon/project-template {:git/tag "v1.0.0" :git/sha "14361f1"}}}' -Tnew create :template hoplon/hoplon :name example/address-book

The above command creates an address-book directory wherever you ran it. The directory has the following structure:

address-book/
├── LICENSE
├── package.json
├── public
│   └── index.html
├── README.md
├── shadow-cljs.edn
└── src
    └── example_project
        ├── main.cljs
        └── view.cljs

Enter the directory.

Install node dependencies (you will only need to do this again if you change your npm dependencies on package.json:

npm install

Then run the build tool in development mode:

npx shadow-cljs watch app

Open http://localhost:8000 in your browser. Then, open src/example_project.view.cljs in your favorite text editor. You should see the page reload automatically when you save changes.

Components

HLisp

User-defined components and the browser's native DOM nodes are aligned through a set of conventions we call HLisp.

For example, this HTML fragment:

<span class="urgent">
  <a href="http://www.zombo.com/">Zombo</a>
</span>

can be expressed in ClojureScript using HLisp conventions as:

(h/span :class "urgent"
  (h/a :href "http://www.zombon.com/" "Zombo"))

In the example above:

  1. h/span and h/a are function calls to constructor functions defined in hoplon.core.
  2. Constructor functions parse their arguments for attribute pairs and children, and apply the attribute values and append child nodes to the elements they return.
  3. Strings like "Zombo" are parsed as DOM text nodes and appended to their parent element.

To simplify the programmatic creation of nodes, HLisp also supports map syntax for attributes:

(h/span {:class "urgent"}
  (h/a {:href "http://www.zombon.com/"} "Zombo"))

Relationship to HTML

While HLisp-style ClojureScript and HTML files describe the same thing - the structure of a document or document fragment - HLisp is not an alternative HTML syntax. That is, HLisp is not converted to HTML. It is converted ultimately to JavaScript, via ClojureScript.

As such, HTML files in Hoplon projects usually only serve the purpose of including and loading JavaScript. They are the entrypoint.

Custom Components

Custom components in Hoplon are functions that return other custom components or native browser elements.

Component definition and invocation

(defn say-hello [msg]
  (h/p msg))

(say-hello "Hello world")

Here, say-hello is user-defined function that takes a string argument and returns a p node with a single child, the text node of msg.

Higher-order functions and components

Because components are functions, they can be passed to higher-order functions like map:

(defn make-list [& items]
  (h/ol (map h/li items)))
  
(make-list "one" "two" "three")

In this example:

  1. make-list takes any number of items, which should be strings
  2. The expression (map h/li items) maps the h/li constructor function over each string, resulting in a sequence of li nodes
  3. Each li produced by the map expression is appended to a new ol and the ol is returned. This demonstrates the HLisp convention that sequence arguments to constructor functions are appended to the resulting element.
    • apply could also be used to construct an ol with children: (apply h/ol (map h/li items))

Component libraries

make-list could be extracted to a .cljs file and referenced from other files by putting it in a file called src/app/components.cljs:

(ns app.components
  (:require [hoplon.core :as h]))

(defn make-list [& items]
  (h/ol (map h/li items)))

src/my/app.clj becomes:

(ns my.app
  (:require [app.components :as c]))

(h/defn page
  []
  (h/div (c/make-list "one" "two" "three")))

Medium to large-sized Hoplon applications typically consist of a small number of files with components mounted directly in html files and a set of component libraries in .cljs files.

Component libraries can also be extracted to separate Maven artifacts and distributed via Clojars or some other Maven repository.

defelem convenience macro

The Hoplon-provided component constructors like h/p and h/a can receive attributes either as a map, or as a sequence of attribute name-value pairs succeeded by any number of child components.

This functionality is extended to user-defined custom components using the h/defelem macro for defining components.

For example, consider this updated version of make-list (hoplon.core is abbreviated as h):

(defn make-list [attrs & items]
  (h/ol attrs (map h/li items)))

When it's called like this:

(make-list {:class "big"} "one" "two" "three")

It evaluates to a DOM structure like this:

<ol class="big">
  <li>one</li>
  <li>two</li>
  <li>three</li>
</ol>

But it doesn't work when it's called like this:

(make-list :class "big" "one" "two" "three")

The problem is that the attribute :class is taken as the attrs argument, which needs to be a map in order to be passed to h/ol as an attribute map. The class name, "big", is taken as a child text node, not as the value of the :class attribute.

We can define make-list using defelem so that attributes and childen are properly parsed, and provided as the arguments attrs and kids inside the function body:

(h/defelem make-list [attrs kids]
  (h/ol attrs (map h/li items)))

defelem is a macro defined in the hoplon.core Clojure namespace.

Components with cells

By themselves, components are static. That is, they don't change in response to events from the user or server. Without reactivity, components described with HLisp offer no benefit over regular markup such as that found in HTML files.

Cells provide a way to model changes in data in a SPA in response to events. Cells can be wired to components, and so can be used to dynamically modify the attributes or children associated with a component.

Cells are:

  • reference types like atoms
  • provided by the hoplon/javelin library
  • similar to the cells in spreadsheets; automatically and consistently propagate data dependency changes
  • the way interactivity and reactivity in Hoplon applications are modeled
  • not tied to the other parts of Hoplon; can be used in any ClojureScript project

This section will not be a comprehensive explanation of the Javelin library. Instead, we will introduce Javelin cells and then focus mostly on the relationship between cells and components in a Hoplon SPA. For a thorough introduction to Javelin you can check out the Javelin README.

Cells crash course

Including Javelin functions and macros

In a regular .cljs file, you can include Javelin using something like this:

(ns your-ns
  (:require [javelin.core :refer [cell cell=]]))

Input cells

The javelin.core/cell function is used to create new "input" cells.

Input cells can do everything ClojureScript's native atoms can do:

; Create a cell named x with an initial value of 0
(def x (cell 0)) 

; Increment the value of x
(swap! x inc)

; Set the value of x to 0
(reset! x 0)

; Print the new value of x every time it changes
(add-watch x :log (fn [_ _ _ v] (.log js/console v)))

In addition, input cells can contribute to the value of formula cells, which we'll get into shortly.

In practice, input cells are used to "collect" the values of events coming from the user or server, such as clicks, key presses, or updates.

In the callback functions for events, new values are saved to one or more input cells with swap! or reset!.

The practice of putting the value associated with an event into a cell in response to an event is an alternative to calling more functions or firing more events. Cells provide an important level of indirection that promotes a value-oriented — not event-oriented — way of modeling an application's behavior.

Instead of an application's behavior being defined directly by the order in which events are fired, the behavior depends directly on values contained in cells.

The use of cells to model behavior is the primary means by which Hoplon applications mitigate callback hell.

Input cell counter example

In the following example, the counter cell is incremented, and its value printed to the JavaScript console every time the button is clicked:

(ns my.example
  (:require
    [hoplon.core :as h]
    [javelin.core :refer [cell]]))

(def counter (cell 0))

(add-watch counter :log (fn [_ _ _ v] (.log js/console v)))

(h/defelem view []
  (h/div
    (h/button :click #(swap! counter inc) "Increment!")))

Hoplon HTML constructor functions can take any jQuery event keyword and a callback as an attribute name/value pair. We leverage this in the above example to attach an anonymous callback that swaps counter every time the button's :click event is fired.

In this most simple example, cell could be substituted for atom and the behavior would be identical. To really take advantage of cells, we must introduce formula cells.

Formula cells

Formula cells are like input cells and atoms in that they contain values. Unlike input cells and atoms, their values cannot be directly modified with a call to swap! or reset!.

Instead, the value of a formula cell is given by the continuous application of a function to other cells. That is, a formula cell's value is the return value of a function that's applied whenever its arguments change.

Point of clarification: lenses

There is a hybrid formula/input cell, called a "lens" in Javelin parlance, which is something like a formula that can be modified. It's a kind of cell that specifies both how a value should be computed and how the input cells contributing to the value should be modified.

Lenses are also created with the formula function. For more on lenses, check out the Javelin README.

Formula cell counter example

Here, we augment the counter example by adding a new formula cell, even. It contains true or false, depending on whether or not counter is even:

(ns my.example
  (:require
    [hoplon.core :as h]
    [javelin.core :refer [cell formula]]))

(def counter (cell 0))

(def even ((formula even?) counter))

(add-watch counter (fn [_ _ _ v] (.log js/console v)))
(add-watch even (fn [_ _ _ v] (.log js/console v)))

(h/defelem view []
  (h/div
    (h/button :click #(swap! counter inc) "Increment!")))

The formula function

The even cell is created with the formula function like this:

  1. (formula even?) returns a function that should be passed the same number of arguments as even?.
  2. ((formula even?) counter) returns a formula cell, the value of which is governed by the continuous application of even? to counter's value.
  3. (def even ((formula even?) counter)) assigns the formula cell to the global name even.

As a reference type like input cells and atoms, formula cells created with formula can be dereferenced at any time.

The following code snippet would print the value of even, once, to the console:

(.log js/console @even)

Note: Dereferencing cells is useful when performing side-effects in callback functions, but in general should be avoided.

Mapping cells to components

In the typical Hoplon application, cells form the "data layer" and components form the "view layer".

Events produced by components contribute new values to cells, and in turn zero or more components are updated.

This flow of activity can be thought of abstractly as a loop, but concretely, it is not, because the occurrence of an event does not necessarily result in the changing of a value in a cell.

Because the loop, in practice, is broken by one or more "event gaps", we can consider the data flow unidirectional.

Note: Javelin/cells make no effort to detect cyclical cell relationships. It's entirely possible to create cycles — and freeze the browser — by linking cells in a loop through a pair of add-watch calls. This almost never happens if you've sufficiently avoided dereferencing cells (see above) but is useful to know about regardless.

We've already seen how event callbacks can modify input cell values in their callbacks. In this section we will examine how the values of cells are "mapped" to components.

In particular, we will examine the three ways a cell value can influence the way a component looks:

  1. Setting attributes
  2. Appending text, a special case of appending children
  3. Appending children components

Setting attributes

HTML attributes and JS events (on!, do!)

Appending text

// Show text macro

Appending children

// for-tpl

Appendix A: Comparison of Components and Cells to React

Appendix B: CQRS

Appendix C: Alternative Backends

FAQ

  1. Routing?
  2. Authentication?

Glossary of terms, functions, macros, acronyms

Term Definition
cell javelin.core/cell, function, creates a new input cell: (def n (cell 0))
cell= javelin.core/cell=, macro, creates a new formula cell (def n+1 (cell= (inc n)))
component A ClojureScript function that returns an HTML element that can be embedded in a page. Used to extend HTML by modularizing behaviors and abstracting over other components and HTML elements.
component constructor Another term for "component", above.
ClojureScript The ClojureScript language and compiler, a dialect of Clojure that targets the JavaScript platform. .cljs.hl files are compiled by the hoplon task into .cljs files that are converted into JavaScript by the ClojureScript compiler.
defelem hoplon.core/defelem, macro, convenience macro for creating components, or functions that return HTML objects. Handles "boxing" attributes and children. e.g. (defelem example [attrs kids] {:attrs attrs, :kids kids}) when called like (example :foo 1 :bar 2 (p "lol")) returns {:attrs {:foo 1, :bar 2}, :kids [#<[object HTMLParagraphElement]>]}
defc javelin.core/defc, macro, creates a new input cell (defc n 0)
defc= javelin.core/defc=, macro, creates a new formula cell (defc= n+1 (inc n))
formula javelin.core/formula, function, converts "normal" functions into functions of cells that participate in Javelin's graph of cells. e.g. (cell= (println x)) is equivalent to ((formula println) x). cell= compiles into ClojureScript code that calls the formula function. formula can be used from JavaScript.
Hoplon The Hoplon Clojure and ClojureScript web framework
Javelin The Javelin ClojureScript library bundled with Hoplon
SPA "Single-page Application", a browser-based application such as GMail that leverages the web browser as a rich client instead of as a hyperlinked document viewer. Popular alternative to desktop apps since around when XMLHttpRequest/Ajax techniques entered the mainstream in 2007. Hoplon was built specifically to build SPAs.
Clone this wiki locally