Imagining a sample TodoMVC with an immutable database
As an exercise to see what the developer experience might look like, I outlined what it might look like for a React-like single-page app. The app that's outlined below is a variation on the TodoMVC.
In the last post, I detailed the various problems that could be solved by an immutable database on the front-end. In summary, the four state management problems, and how they are solved by an immutable database are as follows:
- 1️⃣ State is a cross-cutting concern: Now we can query both UI state and server state from anywhere.
- 2️⃣ Weak equivalence hinders re-rendering: No more
useCallback
,useMemo
, orkey
annotations to help optimize rendering. The renderer can figure that all out on its own. - 3️⃣ Distance between code and data breeds complexity: Developers can treat server state as local using database replication. The data will automatically get synced.
- 4️⃣ State transitions need linear traces: Application state is now observable by querying the transaction log of the immutable database.
As an exercise to see what the developer experience might look like, I outlined what it might look like for a React-like single-page app. The app that's outlined below is a variation on the TodoMVC.
The Todo MVC
The outline is of a TodoMVC with a slight variation. It will have a side panel that holds details of the focused task. There will be a main panel with a list of tasks and a side panel with the details of the task.
Most of the uninteresting components will be at the end as a reference to fill in details, such as the data model and pure functional components. Let's focus on the interactive components with state.
Main panel: a list of tasks
Here's the main panel with a list of tasks.
let MainPanel = () => {
let tasks = useQuery(
{ find: ["?t", "?isDone", "?title", "?assignedTo"] },
{ where: [
["?t", "task/assignedTo", "?u"],
["?a", "app/selectedUser", "?u"],
["?t", "task/isDone", "?showComplete"],
["?a", "app/taskCompleteFilter", "?showComplete"],
["?t", "task/isDone", "?isDone"],
["?t", "task/title", "?title"],
["?u", "user/name" "?assignedTo"],
] })
return <div>
<TaskFilterByCompletion />
<ul>
{tasks.map((task) => {
<TaskLineItem task={task} />
})
</ul>
</div>
}
The main panel does two things: query for the tasks and show the list of tasks. The list of tasks can be filtered for done or incomplete tasks with the TaskFilterByCompletion
component.
The front-end has an immutable database that acts as the store. It holds both the application state and the remote server state. In this case, it holds the task filter criteria (selectedUser
and taskCompleteFilter
) and the list of tasks respectively. This means the list of tasks can be retrieved with a join in a single query.
With a persistent data structure, all objects and functions would know whether they've changed between renders.
Side Panel: Task details
let SidePanel = () => {
let user = useQuery(
{ find: ["?u", "?name"] },
{ where: [
["?u", "user/name", "?name"],
["?a", "app/selectedUser", "?name"],
] }
);
let task = useQuery(
{ find: ["?t", "?title", "?description", "?labels"] },
{ where: [
["?t", "task/title", "?title"],
["?a", "app/selectedTask", "?t"],
["?t", "task/description", "?description"],
["?t", "task/labels" "?l"],
["?l", "label/name" "?labels"],
] }
);
return <div>
<UserSelector user={user} />
<TaskDetails task={task} />
</div>
}
In the side panel, we query for the user that's currently being selected, and the details of a task.
Because the state is accessible anywhere in the component tree, we can query for the application state here, and pass what we need down to the pure functional components. There is no useState
to pull up and down the component hierarchy. There is no prop drilling from the top-level store. There is no top-level Context
component to nest and split up to optimize rendering.
With a single store for application state accessible by query anywhere in the component tree, it helps solve the problem of cross-cutting concerns of state in an interactive app.
Interactive Components
The interactive components are where the action's at, so let's take a look at the TaskLineItem
let TaskLineItem = (task: Task) => {
let db = useDb();
let changeTaskTitle = useTx(
{ in: ["?id, "?title"] },
{ update: [ ["?id", "task/title", "?title"] ] }
);
let handleToggleDone = (_) => {
db.tx({ "db/id": task.id, "task/isDone": !task.isDone });
}
let handleChangeTaskName = (evt) => {
changeTaskTitle({ id: task.id, title: evt.value });
}
let handleFocus = () => {
db.selectTask({ selectedTask: task.id });
}
let handleBlur = () => {
db.selectTask({ selectedTask: null });
}
return <li onFocus={handleFocus} onBlur={handleBlur}>
<Checkbox isChecked={task.isDone} onToggle={handleToggleDone} />
<TextField text={task.title} onChange={handleChangeTaskName} />
</li>
}
There is no query here, because the component is passed the task, but the query could have existed as just well here. When a database is immutable and local, there is not an N+1 problem, and just querying for a task when you need it within a component is not a problem.
Outside of a query for data, it has handlers and transaction queries to change the state. In handleToggleDone
, the transaction is written directly inside the handler. In changeTaskTitle
, the transaction can be specified as a locally reusable hook. Lastly, a transaction can be centralized across an application in a single file, so that state transitions for a particular feature can be seen all in the same place. It's also possible to add guards to these transactions, because they're effectively state transitions in a state machine.
// specify direct queries, or as a higher level state machine
let db.selectTask = useTx(
{ in: ["?selectedTask"] },
{ where: [ ["?a", "app/selectedTask"] ] },
{ update: [ ["?a", "app/selectedTask", "?selectedTask"] ] },
);
An advantage of having a single store is that state transitions are all in the same place, and they can be linearized to aid in understanding. This is possible either by inspecting the file defining the state machine, or better yet, by querying the transaction log of the database.
We can ask the database all the state transitions that lead to the current value shown in a UI. When a user hits an error, the transaction events can be sent over to the developers to reproduce the bug exactly. This can drastically help debugging.
Query language
The query is modeled after Datomic's variant of Datalog. One realization after doing this exercise, is that while Datalog is powerful and simple to join, it's quite verbose.
A major requirement of any query language to make this work are two-fold:
- The query has to be parameterizable. The
where
clauses are usually filter conditions for the query that needs input from the user. We typically do string interpolation for raw SQL queries which is a security issue. ORMs do this right by make queries parameterizable. GraphQL also does this, but it's a little verbose. - The query has to be composable. SQL does have subqueries and common table expressions now. GraphQL has fragments. Both are great help, but I don't see a lot of tools or organizing principles to help developers organize these into a useful library of composable queries.
I'll have to have a closer look at LINQ, CTE, and Imp to see if there's something that works for this problem.
References to other parts of the Todo MVC that isn't particularly important to the discussion, but help fill out the mental model:
Data model
The data model is a little bit more involved with task details such as labeled and the assigned user, but nothing too complicated.
type Task = {
isDone: boolean;
title: string;
assignedTo: Option<User>;
labels: List<Label>;
}
type User = {
name: string;
}
type Label = {
name: string;
}
Top level page
let App = () => {
return <div>
<MainPanel />
<SidePanel />
</div>
}
Pure Display Components
let TaskDetails = (task: Task) => {
return <>
<h4>{task.title}</h4>
<p>{task.description}</p>
<Labels labels={task.labels} />
</>
}
let Label = (label: Label) => {
return <span>
{label.name}
</span>
}