Factui
Declarative UI State Management for React
Install / Use
/learn @arachne-framework/FactuiREADME
FactUI
FactUI is a library for efficiently rendering user interfaces declaratively, based on a tuple-oriented data store.
Rationale
Other ClojureScript/React frameworks have already broken important ground in establishing a pattern for data-driven interfaces: the visible UI is a pure function of data. This is a strong concept which has lead to powerful, simple UI programming idioms.
However, this pattern leaves room for different answers to the following questions:
- What is the structure of the "application data" that ultimately drives the UI?
- What is the pattern for making updates to the application data?
Each library answers these questions in its own way.
FactUI gives its own answers, answers which resolve a number of particularly difficult issues that often end up being pain points (especially in larger applications.)
In broad strokes:
- Application state is stored in a single immutable value, in an atom.
- Components register queries against the application state.
- When the app-state atom is updated, only components with changed query results will re-render.
Query notification on updated facts is provided by the RETE algorithm, which is designed specifically to provide efficient activiation of rules in response to new facts. FactUI uses Clara, an excellent RETE implementation for ClojureScript.
Usage
Note: This section provides documentation using FactUI's integration with the Rum React wrapper. FactUI is not tightly coupled with Rum, it is entirely possible to build integration between FactUI and alternative wrappers (such as Reagent or Om with relatively little effort. However, those have not yet been built.
Initialization
Because queries and rules are built at compile time, FactUI requires a bit of setup.
Step 1: build a rulebase
First, you need to invoke the factui.api/rulebase macro at the top level of one of your source files, passing it a rulebase name and any number of namespace names. You must call rulebase after you :require the namespaces it mentions. Clara will search each of the specified namespaces for query and rule definitions and compile them into a rulebase at macro-expansion time, binding it to a var with the specified name.
(ns example.core
(:require [factui.api :as f :include-macros true]
[example.ns-with-rules]
[example.other-ns-with-rules]))
(f/rulebase my-rulebase
example.ns-with-rules
example-other-ns-with-rules)
Step 2: define some schema
FactUI requires that you define schema for all the attributes you want to insert. Although adherance to the schema is not rigorously validated (for performance reasons), some attributes are important to ensure correct update semantics, particularly :db/cardinality, :db.type/ref :db/isComponent and :db/unique.
Schema txdata is structured the same way as it is in Datomic. For example:
(def schema
[{:db/ident :tasklist/title
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one}
{:db/ident :tasklist/tasks
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many
:db/isComponent true}
{:db/ident :task/title
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one}
{:db/ident :task/completed
:db/valueType :db.type/boolean
:db/cardinality :db.cardinality/one}])
Step 3: Define a component
Using the Rum wrapper, components are defined the same way as they always are in Rum. FactUI is introduced by calling the factui.rum/q mixin. q is a macro that defines a Clara query, and also resolves to a Rum mixin that subscribes to the defined query and causes the component to be re-rendered whenever the query results change.
(ns example.ns-with-rules
(:require [rum.core :as rum]
[factui.api :as f :include-macros true]
[factui.rum :as fr :refer [*results*] :include-macros true]))
(rum/defc TaskList < rum/static
(fr/q [:find ?list-title ?task-title
:in ?task-list
:where
[?task-list :tasklist/title ?list-title]
[?task-list :tasklist/tasks ?task]
[?task :task/title ?task-title])
[app-state ?task-list]
(let [list-title (ffirst *results*)
task-titles (map second *results*)]
[:div
[:h1 list-title]
[:ul
(for [title task-titles]
[:li.task {:key title} title])]]))
There are several elements here to unpack.
- The
rum/staticmixin is also used. You may use any other mixins in combination with the FactUI mixin, and usingrum/staticis reccomended to prevent the component from re-rendering when neither its arguments nor underlying query change. - The body of the
qmacro is a Datomic-style query, which is compiled and defined as a Clara query. - the
[app-state ?task-list]vector defines the arguments to the component. Components with the FactUI mixin have special requirements about the arguments they are passed:- The first argument must be an atom containing the application state.
- The next N arguments are passed as inputs to the query (those defined for the query's
:inclause), where N is the number of arguments specified (this may be zero for queries with no:inclause). - Any additional arguments after the N query inputs are passed on the Rum component as usual and are not processed by the FactUI mixin.
- By convention, arguments that map to query inputs use a symbol with a leading
?to visually disambiguate them. This is not required.
- The
factui.rum/*results*dynamic variable is bound within a component's body. It contains Datomic-style query results, in this case a set of[list-title task-title]tuples. - The remainder of the body is used as the implementation for the React component's
renderlifecycle method and should return a single component or Virtual DOM node, as usual (or a Hiccup data structure representing a DOM node, as per Rum.)
Step 3: initialize rendering
Now you can initialize rendering of your application. FactUI's rum wrapper provides a single function to initialize an app state atom. The wrapper also handles ensuring that the application can successfully reload (preserving application state) after a recompile during development (such as from Figwheel.)
factui.rum/initialize takes 3 arguments:
- A var identifying the rulebase to use.
- The schema to use.
- A function which will be called with an atom containg the application state session on initial load, and whenever the page reloads.
The return value is an atom containing an initialized but empty application state value.
factui.rum/initialize is usually invoked from within your application's main function, like so:
(defn ^:export main
[]
(let [dom-root (.getElementById js/document "root")
mount (fn [app-state] (rum/mount ((deref '#RootComponent) app-state)
dom-root))
app-state (fr/initialize #'rulebase schema mount)]
(fr/transact! initial-data)))
Note again that the argument for the rulebase must be a var, not a value. This ensures that it can be reloaded after being redefined by hiccup. The user-supplied mount function in this example dereferences a var for the same reason; so the new value will get picked up after a Figwheel page reload.
If you are using Figwheel, you should also set the factui.rum/refresh function as the value of Figwheel's :on-jsload config key to ensure that your application refreshes correctly after a Figwheel reload.
Step 4: transacting some data
You will have noticed the use of factui.rum/transact! in the above example. transact! is a tiny wrapper around FactUI's core API, which calls swap! on the application state atom, transacting the specified txdata and returning a map of tempid bindings that were resolved in the transaction.
An example value for initial-data might be:
(def initial-data
[{:db/id -42
:tasklist/title "My Tasks"
:tasklist/tasks [{:task/title "Write a readme"
:task/completed true}
{:task/title "Write an example"
:task/completed false}]}])
Calling transact! with this value would update the app-state atom and return {-42 <new-eid>} (or whatever the new entity ID is.)
Some applications will call transact! directly in event handlers in components. Others may wish to pass around a channel, use a global pub-sub system, or some other intermediary mechanism to give the system a chance to track or modify changes before they are transacted.
That's the basics! The tools described above are enough to get you started writing declarative UIs with FactUI.
Other Features
Standalone queries
You can define named queries separately from a component using the factui.api/defquery macro. They have the same syntax as queries defined in factui.rum/q:
(f/defquery tasks-for-list
"Find the IDs of all tasks in the given task list"
[:find [?task ...]
:in ?tasklist
:where
[?tasklist :tasklist/tasks ?task]])
You can then get the results of the query using the factui.api/query function, passing it a session (not the application stat atom itself, but the value which is the contents of that atom), the query name, and any arguments:
(f/query @app-state tasks-for-list 10042)
;; => #{10049 100029 100037#}
Note that queries performed in this manner are not reactive: they simply retrieve results, and do not set up any kind of notification or re-render process.
Standalone rules
You can also define st
Related Skills
node-connect
343.3kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
frontend-design
92.1kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
openai-whisper-api
343.3kTranscribe audio via OpenAI Audio Transcriptions API (Whisper).
qqbot-media
343.3kQQBot 富媒体收发能力。使用 <qqmedia> 标签,系统根据文件扩展名自动识别类型(图片/语音/视频/文件)。
