Stateless, Data-driven UIs
How to build data-driven components and what they're good at
Build simpler, more testable UIs with pure functions and data. Separate rendering from domain logic and state, and finally enjoy true functional programming when building user interfaces.
Build user interfaces with hiccup — plain old Clojure data literals like vectors, keywords, maps and strings. Render to strings on the server or render (and re-render) the live DOM in browsers, just like you would with React and its peers.
[:div.media-thumb
[:a {:href "https://vimeo.com/861600197"}
[:img.rounded-lg
{:src "/images/data-driven.png"
:alt "Watch Stateless, Data-driven UIs"}]
[:div.overlay
[:a.btn.btn-circle
{:data-theme "cupcake"
:href "https://vimeo.com/861600197"}
[:svg.h-4.w-4
{:xmlns "http://www.w3.org/2000/svg"
:viewBox "0 0 256 256"
:style {:display "inline-block"
:line-height "1"}}
[:path {:d "M72,39.88V216.12a8,8,0,0..."
:fill "none"
:stroke "currentColor"}]]]]]]
Hiccup is highly expressive and unlike JSX does not require any additional build step — it's just Clojure. Replicant's dialect supports some features not found in other libraries, learn more in the hiccup reference.
Structure your UI in reusable bits and pieces with regular Clojure functions. Keep these pure functions in cljc files and use them on the server or on the client. No framework specific component abstractions required.
(defn Media [{:keys [theme thumbnail url
title text button]}]
[:div.media {:data-theme theme}
[:aside.media-thumb
(Thumbnail thumbnail)]
[:main.grow
[:a.hover:underline {:href url}
[:h2.font-bold title]
[:p text]]]
(Button (assoc button :style :ghost))])
While domain-aware UI components like Video
and LikeButton
can look neat in
quick demos and conference talks, coupling the business domain with
rendering logic scales poorly and leads to duplicated UI code. Replicant
encourages generic data-driven UI elements by not providing a stateful
component abstraction. Learn why generic elements
improve frontend code-bases.
Even event handlers can be data, giving you the option of handling them all in a single global handler function. Your UI remains pure data, event handlers declare their intended effects, and you can trivially test the UI even when it supports user interactivity.
(replicant.dom/set-dispatch!
(fn handle-dom-event [rd [action]]
(case action
::search-videos
(let [q (-> rd :replicant/dom-event
.-target .-value)]
(swap! store search-videos q)))))
(defn render [state]
[:div
[:h2 "Parens of the dead episodes"]
[:label.input-field
[:input.grow
{:type "text"
:placeholder "Search"
:on {:input [::search-videos]}}]
(icons/render icon)]
(MediaList
{:medias (->> (:results state)
(map video->media-data))})])
Replicant imposes no structure on your event data — it's just passed to
the global event handler. You're free to make your own declarative
interactivity DSL. Oh, and event handlers can be regular functions as
well.
Learn more about event handlers.
Replicant allows you to specify overrides for any attributes during mounting and unmounting. This allows you to declaratively transition elements on mount and unmount, like fading in an element as it's mounted:
(when (:visible? props)
[:div {:style {:transition "opacity 0.25s"
:opacity 1}
:replicant/mounting {:style {:opacity 0}}
:replicant/unmounting {:style {:opacity 0}}}
(Media
{:thumbnail {:image "/images/christian.jpg"
:size 12}
:title "Christian Johansen"
:text (list "Just wrote some documentation for Replicant."
[:span.opacity-50 "Posted December 11th 2024"])})])
Just wrote some documentation for Replicant
Posted December 11th 2024
This is not limited to inline styles, you can stick any attribute in :replicant/mounting
and :replicant/unmounting
, like :class
. Learn more tidbits like this in the hiccup reference.
Custom element aliases can extend Replicant's hiccup vocabulary. Aliases are stateless wrappers that can expand to arbitrary hiccup. Alias functions are only called when their arguments change, and they can receive side-chained data, removing the need to pass certain data everywhere. Perfect for data that is static (e.g. i18n dictionaries), or change very infrequently (e.g. locales).
[:div.media
[:aside.media-thumb
[:img.rounded-lg {:src (:person/profile-pic author)}]]
[:main.grow
[:h2.font-bold (:person/full-name author)]
[:p (:post/text post)]
[:p.opacity-50
[:i18n/k ::posted {:date (:post/created-at post)}]]]]
Just wrote some documentation for Replicant
Posted December 11th 2024
In this example, :i18n/k
is a user-provided extension
that uses a library to seemingly give Replicant built-in i18n
capabilities. And it's all still data. Learn more about aliases, and check out the i18n tutorial.
When the entire UI is represented by data created by a pure function, testing is trivial: Pass in some data, assert that it appears in the UI somehow. Rinse and repeat. You can even verify that event handlers will "do" what you expect, as long as they're expressed as data.
(defn MediaList [{:keys [title medias]}]
[:div.media-list
(when title (typo/h2 title))
(map Media medias)])
(defn prepare-ui-data [{:keys [user video]}]
{:title (str (count videos) " videos")
:medias (map #(video->media-data user %) videos)})
;; Take some domain data
(->> @store
;; Convert it to UI data
prepare-ui-data
;; Convert it to hiccup
MediaList
;; ...and render it
(r/render el))
(deftest prepare-ui-data-test
(testing "Uses episode number for title"
(is (= (->> {:videos [{:episode/number 1}]}
prepare-ui-data :medias first
:title)
"Episode 1")))
(testing "Includes a like button"
(is (= (->> {:videos [{:video/id "v898900"
:episode/number 1}]
:user {:user/id "u09b"}}
prepare-ui-data :medias first
:button)
{:title "Like video"
:icon :phosphor.regular/heart
:on {:click [[:command/like-video
{:user/id "u09b"
:video/id "v898900"}]]}}))))
Pure functions like video->media-data
capture the essentials of turning your domain data into a visual user
interface without the volatile details of markup — excellent targets for
plain old unit tests. No elaborate browser automation required. Check out
the getting started tutorial
for a practical demonstration.
Functional programming in the UI? It always felt like a pipe dream, and React was the closest we would ever get. Then I stumbled across Replicant and the game changed completely. I hadn’t realized how much I had longed for building fully data oriented UIs!
Peter Strömberg, frontend developer, creator of Calva
With Replicant you always render the entire UI, starting at the root node. There are no atoms, subscriptions, sub-tree rendering, component-local state, or other moving parts. Just a pure function that takes in your domain data and returns the entire UI. Boring in a good way. Simple AND easy.
Learn more about how Replicant makes this possible, and why you
shouldn't worry about performance in
Why top-down rendering is the best frontend
programming model.