Clojurescript React library enabling data-driven architecture
rehook is built from small, modular blocks - each with an explicit notion of time, and a data-first design.
The core library does two things:
- marry React hooks with Clojure atoms
- avoids singleton state
Its modular design, and guiding philosophy has already enabled some rich tooling like rehook-test.
- cljspad
- reax-synth -- react native oscillator (demos re-frame like abstractions, integrant, etc)
- todomvc
- rehook-test
The documentation assumes you are using shadow-cljs.
You will need to provide your own React dependencies, eg:
npm install --save react
npm install --save react-dom
- rehook/core - base state/effects fns
- rehook/dom - hiccup templating DSL
- rehook/test - test library
To include one of the above libraries, add the following to your dependencies:
[rehook/core "2.1.11"]
To include all of them:
[rehook "2.1.11"]
If you need a primer on React hooks, the API docs are a good start.
rehook.core
exposes 5 useful functions for state and effects:
use-state
convenient wrapper overreact/useState
use-effect
convenient wrapper overreact/useEffect
use-atom
use a Clojure atom (eg, for global app state) within a componentuse-atom-path
likeuse-atom
, except for a path into a atom (eg,get-in
)use-atom-fn
provide custom getter/setter fns to build your own abstractions
(ns demo
(:require
[rehook.core :as rehook]
[rehook.dom :refer-macros [defui]]
[rehook.dom.browser :as dom.browser]
["react-dom" :as react-dom]))
(defn system [] ;; <-- system map (this could be integrant, component, etc)
{:state (atom {:missiles-fired? false})})
(defui my-component
[{:keys [state]} ;; <-- context map from bootstrap fn
props] ;; <-- any props passed from parent component
(let [[curr-state _] (rehook/use-atom state) ;; <-- capture the current value of the atom
[debug set-debug] (rehook/use-state false) ;; <-- local state
[missiles-fired? set-missiles-fired] (rehook/use-atom-path state [:missiles-fired?])] ;; <-- capture current value of path in atom
(rehook/use-effect
(fn []
(js/console.log (str "Debug set to " debug)) ;; <-- the side-effect invoked after the component mounts and debug's value changes
(constantly nil)) ;; <-- the side-effect to be invoked when the component unmounts
[debug])
[:section {}
[:div {}
(if debug
[:span {:onClick #(set-debug false)} "Hide debug"]
[:span {:onClick #(set-debug true)} "Show debug"])
(when debug
(pr-str curr-state))]
(if missiles-fired?
[:div {} "Missiles have been fired!"]
[:div {:onClick #(set-missiles-fired true)} "Fire missiles"])]))
;; How to render a component to the DOM
(react-dom/render
(dom.browser/bootstrap
(system) ;; <-- context map
identity ;; <-- context transformer
clj->js ;; <-- props transformer
my-component) ;; <-- root component
(js/document.getElementById "myapp"))
- When using
use-effect
, make sure the values ofdeps
pass JavaScript's notion of equality! Solution: use simple values instead of complex maps. - Enforced via convention, React hooks and effects need to be defined at the top-level of your component (and not bound conditionally)
rehook.dom
provides hiccup syntax.
rehook.dom
provides a baggage free way to pass down application context (eg, integrant or component) as you will see below.
rehook.dom/defui
is a macro used to define rehook
components. This macro is only syntactic sugar, as all rehook
components are cljs fns.
defui
takes in two arguments:
context
: immutable, application contextprops
: any props passed to the component
It must return valid hiccup.
(ns demo
(:require [rehook.dom :refer-macros [defui]]))
(defui my-component [{:keys [dispatch]} _]
[:text {:onClick #(dispatch :fire-missiles)} "Fire missiles!"])
The anonymous counterpart is rehook.dom/ui
Use the :<>
shorthand:
(defui fragmented-ui [_ _]
[:<> {} [:div {} "Div 1"] [:div {} "Div 2"]])
Reference the component directly:
(defui child [_ _]
[:div {} "I am the child"])
(defui parent [_ _]
[child])
Same as rehook components. Reference the component directly:
(require '["react-select" :as ReactSelect])
(defui select [_ props]
[ReactSelect props])
(require '[reagent.core :as r])
(defn my-reagent-component []
[:div {} "I am a reagent component, I guess..."])
(defui my-rehook-component [_ _]
[(r/reactify-component my-reagent-component)])
;; acceptable
[:div {}
(for [item items]
[item {}])]
;; also acceptable
[:div {}
[child1]
[child2]]
(require '[rehook.util :as util])
(defui parent [_ props]
[:div {}
(for [child (util/child-seq props)]
[child {:onClick #(js/alert "Extra props merged into child!")}])])
...
[parent {}
[:div {:style {:color "pink"}} "I am a child"]]
You can opt-out of hiuccup templating by passing a third argument (the render fn) to defui
:
(defui no-html-macro [_ _ $]
($ :div {} "rehook-dom without hiccup!"))
Because the $
render fn is passed into every rehook component you can overload it -- or better yet create your own custom templating syntax!
A props transformation fn is passed to the initial bootstrap
fn. The return value of this fn must be a JS object.
A good default to use is cljs.core/clj->js
.
If you want to maintain Clojure idioms, a library like camel-snake-kebab could be used to convert keys in your props (eg, on-press
to onPress
)
Props transformation is used for interop with vanilla React components. Therefore, all props passed into rehook do not go through the transformation fn, and remain untouched.
If you need to access the React props in Rehook components (for example, to access children), the JS props computed by React are available as metadata on the props map, under the :react/props
key.
You can use the util fn rehook.util/react-props
to conveniently extract the React props.
You can call react-dom/render
directly, and bootstrap
your component:
(ns example.core
(:require
[example.components :refer [app]]
[rehook.dom.browser :as dom]
["react-dom" :as react-dom]))
(defn system []
{:dispatch (fn [& _] (js/console.log "TODO: implement dispatch fn..."))})
(defn main []
(react-dom/render
(dom/bootstrap (system) identity clj->js app)
(js/document.getElementById "app")))
You can use the rehook.dom.native/component-provider
fn if you directly call AppRegistry
(ns example.core
(:require
[rehook.dom :refer-macros [defui]]
[rehook.dom.native :as dom]
["react-native" :refer [AppRegistry]]))
(defui app [{:keys [dispatch]} _]
[:Text {:onPress #(dispatch :fire-missiles)} "Fire missiles!"])
(defn system []
{:dispatch (fn [& _] (js/console.log "TODO: implement dispatch fn..."))})
(defn main []
(.registerComponent AppRegistry "my-app" (dom/component-provider (system) app)))
Alternatively, if you don't have access to the AppRegistry
, you can use the rehook.dom.native/bootstrap
fn instead - which will return a valid React element
The context transformer can be incredibly useful for instrumentation, or for adding additional abstractions on top of the library (eg implementing your own data flow engine ala domino)
For example:
(require '[rehook.util :as util])
(defn ctx-transformer [ctx component]
(update ctx :log-ctx #(conj (or % []) (util/display-name component))))
(dom/component-provider (system) ctx-transformer clj->js app)
In this example, each component will have the hierarchy of its parents in the DOM tree under the key :log-ctx
.
This can be incredibly useful context to pass to your logging/metrics library!
rehook.dom/defui
-- resolve as defn, indentation asindent
rehook.dom/ui
-- resolve as fn, indentation asindent
rehook.test/defuitest
-- resolve as defn, indentation asindent
rehook.test/initial-render
-- indentation as1
rehook.test/next-render
-- indentation as1
rehook.test/io
-- indentation as1
rehook.test/is
-- indentation as1
Add this to your cljfmt config:
{rehook.dom/defui [[:inner 0]]
rehook.dom/ui [[:inner 0]]}
Add this to your .clj-kondo/config.edn
file:
{:lint-as {rehook.dom/defui clojure.core/defn
rehook.dom/ui clojure.core/fn}}
rehook allows you to test your entire application - from data layer to view.
How? Because rehook
promotes building applications with no singleton global state.
Therefore, you can treat your components as 'pure functions', as all inputs to the component are passed in as arguments.
rehook-test supports:
- server, react-dom and react-native
- cljs.test + nodejs target for headless/CI
- browser for devcards-like interactive development
- whatever else you can think of. it's just a function call really.
A demo report generated from rehook's own todomvc tests can be found here
Write tests, and get reports like this:
And headless node cljs tests!
Writing tests for rehook is not dissimilar to how you might test with datomic or Kafka's TopologyTestDriver, with a bit of devcards in the mix.
Each state change produces a snapshot in time that rehook captures as a 'scene'.
Like Kafka's ToplogyTestDriver, the tests run in a simulated library runtime.
However, a read-only snapshot of the dom is rendered for each scene (as you can see above)!
This allows you to catch any runtime errors caused by invalid inputs for each re-render.
Note: while documentation is improving, please check out the rehook tests for a reference on how to use the API.
rehook.test
wraps the cljs.test API with a bit of additional syntactic sugar.
This means rehook tests compile to something cljs.test
understands!
(ns todo-test
(:require [rehook.test :as rehook.test :refer-macros [defuitest is io initial-render next-render]]
[rehook.demo.todo :as todo]))
(defuitest todo-app--clear-completed
[[scenes ctx] {:system todo/system
:system-args []
:shutdown-f identity
:ctx-f identity
:props-f identity
:component todo/todo-app}]
(-> (initial-render scenes
(is "Initial render should show 4 TODO items"
(= (rehook.test/children :clear-completed) ["Clear completed " 4]))
(io "Click 'Clear completed'"
(rehook.test/invoke-prop :clear-completed :onClick [{}])))
(next-render
(is "After clicking 'Clear Completed', there should be no TODO items"
(nil? (rehook.test/children :clear-completed)))))
The ->
threading macro is used to chain our tests.
Writing tests consists of using two basic primitives:
rehook.test/io
- wrapping any side-effects that will trigger a re-render (such as DOM events, HTTP calls, etc)rehook.test/is
- likecljs.test/is
, this is how you write assertions for the current render
Each test body (consisting of is
and io
) is scoped to a 'snapshot' of a render:
rehook.test/initial-render
- called on our first testrehook.test/next-render
- trigger a re-render by playing any effects
rehook.test/defuitest
takes in a map describing your application:
{:system todo/system ;; <-- your ctx constructor, eg ig/init
:system-args [] ;; <-- any arguments to your ctx constructor
:shutdown-f identity ;; <-- called after the test has finished, eg ig/halt!
:ctx-f identity ;; <-- likely the same ctx-f passed into your applications bootstrap call
:props-f identity ;; <-- likely the same props-f passed into your applications bootstrap call
:component todo/todo-app}] ;; <-- the rehook component your are writing tests for
Add a unique key named :rehook/id
to the props of any component you want to instrument:
[:div {:rehook/id :my-unique-key} "I will be instrumented!"]
Note: this key gets compiled out when running outside of rehook.test
!
You can then invoke props and view the props and children using the following fns:
rehook.test/children
- returns a collection of childrenrehook.test/get-prop
- returns the props of the componentrehook.test/invoke-prop
- invokes a component's event (eg,onClick
)
You can see these three fns in action in the demo code above.
TODO: provide an easy way to construct mock JS events. Perhaps look to using jsdom?
- The test reports provides a way to view effects and state over time. However, this is provided only as a means of debugging. Both
use-state
anduse-effects
are implementation details - and shouldn't be tested. - Therefore,
rehook-test
is about testing the resulting output of the component. - If you follow a re-frame like pattern of using global app state, it should be possible to inspect your subscriptions and invoke your effects using the
rehook.test
primitives. More documentation to follow.
Note: the graphical test reporter only works for react-dom
tests. It would be great to implement something similar for React Native (using the simulator, expo web preview, etc)!
Create a build in your shadow-cljs.edn
file like so:
{:target :browser
:output-dir "public/js"
:asset-path "/js"
:closure-defines {rehook.test.browser/HTML "<!DOCTYPE html><html><head><link rel=\"stylesheet\" href=\"styles/todo.css\"></head><body><div></div></body></html>" ;; optional, the initial DOM html (eg, the index.html of your actual app)
rehook.test/target "app" ;; optional, the div id where rehook's report renders
rehook.test/domheight 400} ;; optional, the dom preview's iframe height
:devtools {:before-load rehook.test/clear-registry!} ;; add this if using hot reload
:modules {:main {:entries [rehook.test.browser
todo-test] ;; <-- your test nses go here...
:init-fn rehook.test.browser/report}}}
And you are done!
shadow-cljs watch :my-build-id
Will render your test report. As you update your test/application code, the report will also update!
Inside of the folder dev-http
serves, add a report.html
file like this one.
Add a build in your shadow-cljs.edn
file like so:
{:target :node-test
:output-to "out/test.js"}
And you are done!
shadow-cljs compile :my-build-id
node out/test.js
Will run your headless tests
- Better feedback when things don't go as expected (eg,
io
call didn't cause a re-render) - I want Github-level diffs between the previous scene and the next scene's hiccup. react-diff-viewer?
- How can we use clojure spec and perhaps property based testing to put this thing on steroids? Eg, instrument and render shrunk result
- This tool could be used to instrument running app? Eg, reframe10x but on even more steroids :)
- This tool could lint/detect various warnings/runtime problems. Eg, when a :key on a component is required, when state/effects are setup incorrectly, etc