Simple and Decomplected UI library based on React >= 18 focused on performance.
Add to deps.edn
:
funcool/rumext
{:git/tag "v2.20"
:git/sha "7f5e1cd"
:git/url "https://github.com/funcool/rumext.git"}
Rumext is a tool to build a web UI in ClojureScript.
It's a thin wrapper on React >= 18, focused on performance and offering a Clojure-idiomatic interface.
API Reference: http://funcool.github.io/rumext/latest/
It uses Clojure macros to achieve the same goal as JSX format without using anything but the plain Clojure syntax. The HTML is expressed in a format inspired in hiccup library, but with its own implementation.
HTML code is represented as nested arrays with keywords for tags and attributes. Example:
[:div {:class "foobar"
:style {:background-color "red"}
:on-click some-on-click-fn}
"Hello World"]
Macros are smart enough to transform attribute names from lisp-case
to camelCase
and renaming :class
to className
. So the compiled javacript
code for this fragment could be something like:
React.createElement("div",
{className: "foobar",
style: {"backgroundColor": "red"},
onClick: someOnClickFn},
"Hello World");
And this is what will be rendered when the app is loaded in a browser:
<div class="foobar"
style="background-color: red"
onClick=someOnClickFn>
Hello World
</div>
WARNING: it is mainly implemented to be used in Penpot and released as separated project for conveniendce. Don't expect compromise for backwards compatibility beyond what the penpot project needs.
As seen above, when using the Hiccup-like
syntax, you can create a HTML element with a keyword like :div
, :span
or
:p
. You can also specify a map of attributes, that are converted at compile
time into a Javascript object.
IMPORTANT: a Javascript plain object is different from a Clojure plain map. In ClojureScript you can handle mutable JS objects with a specific API, and convert forth and back to Clojure maps. You can learn more about it in ClojureScript Unraveled book.
Rumext macros have some features to pass properties in a more convenient and
Clojure idiomatic way. For example, when using the [:div {...}]
syntax, you
do not need to add the #js
prefix, it's added automatically. There are also
some automatic transformations of property names:
- Names in
lisp-case
are transformed tocamelCase
. - Reserved names like
class
are transformed to React convention, likeclassName
. - Names already in
camelCase
are passed directly without transform. - Properties that begin with
data-
andaria-
are also passed directly. - Transforms are applied only to
:keyword
properties. You can also send string properties, that are not processed anyway.
It's important to notice that this transformations are performed at compile time, having no impact in runtime performance.
There are times when we'll need the element name to be chosen dynamically or constructed at runtime; the props to be built dynamically or created as an element from a user-defined component.
For this purpose, Rumext exposes a special macro: :>
, a general-purpose
handler for passing dynamically defined props to DOM native elements or
creating elements from user-defined components.
To define the element dynamically, just pass a variable with the name as a
first parameter of :>
.
(let [element (if something "div" "span")]
[:> element {:class "foobar"
:style {:background-color "red"}
:on-click some-on-click-fn}
"Hello World"])
To give a dynamic map of properties, you may also give a variable as a second parameter:
(let [props #js {:className "fooBar"
:style #js {:backgroundColor "red"}
:onClick some-on-click}]
[:> "div" props
"Hello World"])
IMPORTANT if you define the attributes dynamically, outside the :>
macro,
there are no automatic transformations. So you need to define the map as a
plain Javascript object with the #js
prefix or any other way. You also need
to use camelCase
names and remember to use className
instead of class
,
for example.
There are a couple of utilities for managing dynamic attributes in a more convenient way.
A macro that allows performing a merge between two props data structures using
the JS spread operator ({...props1, ...props2}
). This macro also performs
name transformations if you pass a literal map as a second parameter.
It is commonly used this way:
(mf/defc my-label*
[{:keys [name class on-click] :rest props}]
(let [class (or class "my-label")
props (mf/spread-props props {:class class})]
[:span {:on-click on-click}
[:> :label props name]]))
A helper macro to create a Javascript props object from a Clojure map, applying name transformations.
An example of how it can be used and combined with mf/spread-props
:
(mf/defc my-label*
[{:keys [name class on-click] :rest props}]
(let [class (or class "my-label")
new-props (mf/props {:class class})
all-props (mf/spread-props props new-props)]
[:span {:on-click on-click}
[:> :label props name]]))
In some cases you will need to make props from a dynamic Clojure object. You
can use mf/map->props
function for it, but be aware that it makes the
conversion to Javascript and the names transformations in runtime, so it adds
some overhead in each render. Consider it if performance is important.
(let [clj-props {:class "my-label"}
props (mf/map->props clj-props)]
[:> :label props name])
You can pass to :>
macro the name of a custom component (see below)
to create an instance of it:
(mf/defc my-label*
[{:keys [name class on-click] :rest props}]
[:span {:on-click on-click}
[:> :label props name]])
(mf/defc other-component*
[]
[:> my-label* {:name "foobar" :on-click some-fn}])
The defc
macro is the basic block of a Rumext UI. It's a lightweight utility
that generates a React function component and adds some adaptations for it
to be more convenient to ClojureScript code, like camelCase
conversions and
reserved name changes as explained above.
For example, this defines a React component:
(require '[rumext.v2 :as mf])
(mf/defc title*
[{:keys [label-text] :as props}]
[:div {:class "title"} label-text])
The compiled javascript for this block will be similar to what would be obtained for this JSX block:
function title({labelText}) {
return (
<div className="title">
{labelText}
</div>
);
}
NOTE: the *
in the component name is a mandatory convention for proper
visual distinction of React components and Clojure functions. It also enables
the current defaults on how props are handled. If you don't use the *
suffix,
the component will behave in legacy mode (see the FAQs below).
The component created this way can be mounted onto the DOM:
(ns myname.space
(:require
[goog.dom :as dom]
[rumext.v2 :as mf]))
(def root (mf/create-root (dom/getElement "app")))
(mf/render! root (mf/html [:> title* {:label-text "hello world"}]))
Or you can use mf/element
, but in this case you need to give the
attributes in the raw Javascript form, because this macro does not have
automatic conversions:
(ns myname.space
(:require
[goog.dom :as dom]
[rumext.v2 :as mf]))
(def root (mf/create-root (dom/getElement "app")))
(mf/render! root (mf/element title* #js {:labelText "hello world"}))
When React instantiates a function component, it passes a props
parameter
that is a map of the names and values of the attributes defined in the calling
point.
Normally, Javascript objects cannot be destructured. But the defc
macro
implements a destructuring functionality, that is similar to what you can do
with Clojure maps, but with small differences and convenient enhancements for
making working with React props and idioms easy, like camelCase
conversions
as explained above.
(mf/defc title*
[{:keys [title-name] :as props}]
(assert (object? props) "expected object")
(assert (string? title-name) "expected string")
[:label {:class "label"} title-name])
If the component is called via the [:>
macro (explained above),
there will be two compile-time conversion, one when calling and another one when
destructuring. In the Clojure code all names will be lisp-case
, but if you
inspect the generated Javascript code, you will see names in camelCase
.
Also like usual destructuring, you can give default values to properties by
using the :or
construct:
(mf/defc color-input*
[{:keys [value select-on-focus] :or {select-on-focus true} :as props}]
...)
An additional idiom (specific to the Rumext component macro and not available
in standard Clojure destructuring) is the ability to obtain an object with all
non-destructured props with the :rest
construct. This allows to extract the
props that the component has control of and leave the rest in an object that
can be passed as-is to the next element.
(mf/defc title*
[{:keys [name] :rest props}]
(assert (object? props) "expected object")
(assert (nil? (unchecked-get props "name")) "no name in props")
;; See below for the meaning of `:>`
[:> :label props name])
Of course the destructure is optional. You can receive the complete props
argument and read the properties later. But in this case you will not have
the automatic conversions:
(mf/defc color-input*
[props]
(let [value (unchecked-get props "value")
on-change (unchecked-get props "onChange")
on-blur (unchecked-get props "onBlur")
on-focus (unchecked-get props "onFocus")
select-on-focus? (or (unchecked-get props "selectOnFocus") true)
class (or (unchecked-get props "className") "color-input")
The recommended way of reading props
javascript objects is by using the
Clojurescript core function unchecked-get
. This is directly translated to
Javascript props["propName"]
. As Rumext is performance oriented, this is the
most efficient way of reading props for the general case. Other methods like
obj/get
in Google Closure Library add extra safety checks, but in this case
it's not necessary since the props
attribute is guaranteed by React to have a
value, although it can be an empty object.
In React there is a mechanism to set a reference to the rendered DOM element, if
you need to manipulate it later. Also it's possible that a component may receive
this reference and gives it to a inner element. This is called "forward referencing"
and to do it in Rumext, you need to add the forward-ref
metadata. Then, the
reference will come in a second argument to the defc
macro:
(mf/defc wrapped-input*
{::mf/forward-ref true}
[props ref]
(let [...]
[:input {:style {...}
:ref ref
...}]))
In React 19 this will not be necessary, since you will be able to pass the ref
directly inside props
. But Rumext currently only support React 18.
The Rumext library comes with two approaches for checking props: simple and malli.
Let's start with the simple, which consists of simple existence checks or
plain predicate checking. For this, we have the mf/expect
macro that receives
a Clojure set and throws an exception if any of the props in the set has not
been given to the component:
(mf/defc button*
{::mf/expect #{:name :on-click}}
[{:keys [name on-click]}]
[:button {:on-click on-click} name])
The prop names obey the same rules as the destructuring so you should use the same names.
Sometimes a simple existence check is not enough; for those cases, you can give
mf/expect
a map where keys are props and values are predicates:
(mf/defc button*
{::mf/expect {:name string?
:on-click fn?}}
[{:keys [name on-click]}]
[:button {:on-click on-click} name])
If that is not enough, you can use mf/schema
macro that supports
malli schemas as a validation
mechanism for props:
(def ^:private schema:props
[:map {:title "button:props"}
[:name string?]
[:class {:optional true} string?]
[:on-click fn?]])
(mf/defc button*
{::mf/schema schema:props}
[{:keys [name on-click]}]
[:button {:on-click on-click} name])
IMPORTANT: The props checking obeys the :elide-asserts
compiler
option and by default, they will be removed in production builds if
the configuration value is not changed explicitly.
You can use React hooks as is, as they are exposed by Rumext as
mf/xxx
wrapper functions. Additionaly, Rumext offers several
specific hooks that adapt React ones to have a more Clojure idiomatic
interface.
You can use both one and the other interchangeably, depending on which
type of API you feel most comfortable with. The React hooks are exposed
as they are in React, with the function name in camelCase
, and the
Rumext hooks use the lisp-case
syntax.
Only a subset of available hooks is documented here; please refer to the React API reference documentation for detailed information about available hooks.
This is analogous to the React.useState
. It offers the same
functionality but uses the ClojureScript atom interface.
Calling mf/use-state
returns an atom-like object that will deref to
the current value, and you can call swap!
and reset!
on it to
modify its state. The returned object always has a stable reference
(no changes between rerenders).
Any mutation will schedule the component to be rerendered.
(require '[rumext.v2 as mf])
(mf/defc local-state*
[props]
(let [clicks (mf/use-state 0)]
[:div {:on-click #(swap! clicks inc)}
[:span "Clicks: " @clicks]]))
This is functionally equivalent to using the React hook directly:
(mf/defc local-state*
[props]
(let [[counter update-counter] (mf/useState 0)]
[:div {:on-click (partial update-counter #(inc %))}
[:span "Clicks: " counter]]))
In the same way as use-state
returns an atom-like object. The unique
difference is that updating the ref value does not schedule the
component to rerender. Under the hood, it uses the useRef
hook.
DEPRECATED: should not be used
Analogous to the React.useEffect
hook with a minimal call convention
change (the order of arguments is inverted).
This is a primitive that allows incorporating probably effectful code into a functional component:
(mf/defc local-timer*
[props]
(let [local (mf/use-state 0)]
(mf/use-effect
(fn []
(let [sem (js/setInterval #(swap! local inc) 1000)]
#(js/clearInterval sem))))
[:div "Counter: " @local]))
The use-effect
is a two-arity function. If you pass a single
callback function, it acts as though there are no dependencies, so the
callback will be executed once per component (analogous to didMount
and willUnmount
).
If you want to pass dependencies, you have two ways:
- passing a JS array as a first argument (like in React but with inverted order).
- using the
rumext.v2/deps
helper:
(mf/use-effect
(mf/deps x y)
(fn [] (do-stuff x y)))
And finally, if you want to execute it on each render, pass nil
as
deps (much in the same way as raw useEffect
works).
For convenience, there is an mf/with-effect
macro that drops one
level of indentation:
(mf/defc local-timer*
[props]
(let [local (mf/use-state 0)]
(mf/with-effect []
(let [sem (js/setInterval #(swap! local inc) 1000)]
#(js/clearInterval sem)))
[:div "Counter: " @local]))
Here, the deps must be passed as elements within the vector (the first argument).
Obviously, you can also use the React hook directly via mf/useEffect
.
In the same line as the use-effect
, this hook is analogous to the
React useMemo
hook with the order of arguments inverted.
The purpose of this hook is to return a memoized value.
Example:
(mf/defc sample-component*
[{:keys [x]}]
(let [v (mf/use-memo (mf/deps x) #(pow x 10))]
[:span "Value is: " v]))
On each render, while x
has the same value, the v
only will be
calculated once.
This also can be expressed with the rumext.v2/with-memo
macro that
removes a level of indentation:
(mf/defc sample-component*
[{:keys [x]}]
(let [v (mf/with-memo [x]
(pow x 10))]
[:span "Value is: " v]))
Is a special case of use-memo
in that the memoized value is a
function definition.
An alias for use-callback
, that is a wrapper on React.useCallback
.
A Rumext custom hook that adds reactivity to atom changes to the
component. Calling mf/deref
returns the same value as the Clojure
deref
, but also sets a component rerender when the value changes.
Example:
(def clock (atom (.getTime (js/Date.))))
(js/setInterval #(reset! clock (.getTime (js/Date.))) 160)
(mf/defc timer*
[props]
(let [ts (mf/deref clock)]
[:div "Timer (deref): "
[:span ts]]))
Internally, it uses the react.useSyncExternalStore
API together with
the ability of atom to watch it.
React allows to create a component that adapts or wraps another component
to extend it and add additional functionality. Rumext includes a convenient
mechanism for doing it: the ::mf/wrap
metadata.
Currently Rumext exposes one such component:
mf/memo
: analogous toReact.memo
, adds memoization to the component based on props comparison. This allows to completely avoid execution to the component function if props have not changed.
(mf/defc title*
{::mf/wrap [mf/memo]}
[{:keys [name]}]
[:div {:class "label"} name])
By default, the identical?
predicate is used to compare props; you
can pass a custom comparator function as a second argument:
(mf/defc title*
{::mf/wrap [#(mf/memo % =)]}
[{:keys [name]}]
[:div {:class "label"} name])
For more convenience, Rumext has a special metadata ::mf/memo
that
facilitates the general case for component props memoization. If you
pass true
, it will behave the same way as ::mf/wrap [mf/memo]
or
React.memo(Component)
. You also can pass a set of fields; in this
case, it will create a specific function for testing the equality of
that set of props.
If you want to create your own higher-order component, you can use the
mf/fnc
macro:
(defn some-factory
[component param]
(mf/fnc my-high-order-component*
[props]
[:section
[:> component props]]))
This project was originated as a friendly fork of rum for a personal use but it later evolved to be a completly independent library that right now does not depend on it and probably no longer preserves any of the original code. In any case, many thanks to Tonksy for creating rum.
This is the list of the main differences:
- use function based components instead of class based components.
- a clojurescript friendly abstractions for React Hooks.
- the component body is compiled statically (never interprets at runtime thanks to hicada).
- performance focused, with a goal to offer almost 0 runtime overhead on top of React.
The usual convention of importing RUM project was to use rum/defc
or
m/defc
. For Rumext the most straightforward abbreviation would have been
mx/defc
. But that preffix was already use for something else. So finally we
choose mf/defc
. But this is not mandatory, it's only a convention we follow
in this manual and in Penpot.
In earlier versions of Rumext, components had a default behavior of
automatically converting the props
Javascript object coming from
React to a Clojure object, so it could be read by normal destructuring
or any other way of reading objects.
Additionally you could use :&
handler instead of :>
to give a
Clojure object that was converted into Javascript for passing it to
React.
But both kind of transformations were done in runtime, thus adding the conversion overhead to each render of the compoennt. Since Rumex is optimized for performance, this behavior is now deprecated. With the macro destructuring and other utilities explained above, you can do argument passing almost so conveniently, but with all changes done in compile time.
Currently, components whose name does not use *
as a suffix behave
in legacy mode. You can activate the new behavior by adding the
::mf/props :obj
metadata, but all this is considered deprecated now.
All new components should use *
in the name.
Licensed under MPL-2.0 (see LICENSE file on the root of the repository)