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.

Imagining a sample TodoMVC with an immutable database

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, or key 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.

2️⃣
Weak equivalence hinders re-rendering:
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.

1️⃣
State is a cross-cutting concern:
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"] ] },    
);
4️⃣
State transitions need linear traces:
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>
}