When you make a website these days, the chances are that the client needs to keep track of some state. You'll need to make some choices regarding how the state should be structured for reading and writing.
The following questions arise:
- Should you save state as nested structures?
- Should you normalize the data into entities and have references between them?
- Should you duplicate data in order to quickly get answers to certain kinds of queries?
In a non-trivial application you quickly end up writing a halfway implementation of an in-memory database, haunted by bugs. To do it correctly is a non-trivial exercise in futility. Especially if you duplicate the same state in the client it's pretty easy to end up in a ditch.
What is the solution?
What we want is to be able to make arbitrary queries without having to hand-code our own data structures, while having all state stored in one place. In other words, a database. To achieve performance, we need indexes, which means tradeoffs in terms of memory usage and write performance, but that's not a big concern these days with powerful clients.
DataScript is a Clojure(Script)-based immutable in-memory database that supports queries via Datalog (think SQL, but better). It's inspired by Datomic, just without history and persistence support.
You create a DataScript database when a page loads. You put data in and retrieve it, and then the database is discarded when the user leaves the page. You can certainly persist it via local-storage in the browser if you want, but you'll have to write that glue code yourself. The point is to get a good API for making arbitrary queries without sacrificing performance.
Example
When using DataScript, you need to declare a schema for your database. This means specifying which attributes are unique and which are references. A reference may be one to one, or one to many
Here is an example where we have modelled animals and the zoos they live in:
(def schema {:zoo/name {:db/unique :db.unique/identity}
:animal/name {:db/unique :db.unique/identity}
:animal/lives-in {:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one}})
Writing data
You add data like this:
(require '[datascript.core :as d])
(def conn (d/create-conn schema))
(d/transact! conn [{:zoo/name "Paris Zoo", :zoo/address "France"}
{:zoo/name "Stockholm Zoo", :zoo/address "England"}]
Here, two zoos are created in a transaction. Note that a zoo has an address, without you needing to declare it in the schema.
If you want to update the address, you use the unique identifier of the entity, :zoo/name
(d/transact! conn [{:zoo/name "Stockholm Zoo", :zoo/adresse "Sweden"}])
We can also add some animals:
(d/transact! conn [{:animal/name "Julius", :animal/age 14, :animal/lives-in {:zoo/name "Paris Zoo"}}
{:animal/name "Barney", :animal/age 3, :animal/lives-in {:zoo/name "London Zoo"}}
{:animal/name "Nellie", :animal/age 5}])
Queries
Now we can retrieve data. Here are some examples.
All animals:
(d/q '[:find [?e ...] :where [?e :animal/name]] db)
=> [1 3 5]
These are the IDs of the entities. We want to retrieve the attributes, and you can do it like this:
(->> (d/q '[:find [?e ...] :where [?e :animal/name]] db)
(map #(d/entity db %))
(map #(select-keys % [:db/id :animal/name :animal/age :animal/lives-in])))
=> [{:db/id 1, :animal/name "Julius", :alder 14, :animal/lives-in {:db/id 2}}
{:db/id 3, :animal/name "Barney", :alder 3, :animal/lives-in {:db/id 4}}
{:db/id 5, :animal/name "Nellie", :alder 5}]
Alternatively, we can use pull syntax to retrieve all attributes directly:
(d/q '[:find [(pull ?e [*]) ...] :where [?e :animal/name]] db)
=> [{:db/id 1, :animal/name "Julius", :alder 14, :animal/lives-in {:db/id 2}}
{:db/id 3, :animal/name "Barney", :alder 3, :animal/lives-in {:db/id 4}}
{:db/id 5, :animal/name "Nellie", :alder 5}]
Find a zoo based on name:
(d/q '[:find (pull ?e [*]) .
:where [?e :zoo/name "London Zoo"]]
db)
=> {:db/id 7, :zoo/address "England", :zoo/name "London Zoo"}
Since name is a unique attribute we can simplify even further:
(d/entity db [:zoo/name "London Zoo"])
To find all wild animals who do not live in a zoo:
(d/q '[:find [(pull ?a [*]) ...]
:where
[?a :animal/name]
(not [?a :animal/lives-in])]
db)
=> [{:db/id 5, :animal/name "Nellie", :alder 5}]]
Summary
We have used DataScript with great success in a client project. You avoid messy, custom-developed state code and can rely on a well-thought-out query language. A bonus is that DataScript follows the same data abstraction as Datomic, which opens up exciting solutions where you can easily stream data from the backend to the frontend. Magnar has a thought-provoking presentation that is worth checking out.
What about JavaScript?
DataScript has a JavaScript API, but it's not particularly tailored for use from JavaScript.
Update 2025-04 Instantdb looks like an interesting service that works with JavaScript.