Building the Digital Abacus pt. 2: Frontend Architecture
Or, how to get p5 and React to like each other.
This post is part of a series about the theory and engineering behind the Digital Abacus Tool, an online playground for modeling mathematical systems.
Table of Contents
Frontend Architecture ← you are here
…
Introduction
In part one of the series I described the theory underlying the Digital Abacus Tool, focusing on our data model: the constraint graph.
Now in part two I’ll discuss a more practical aspect of the project: the tool’s frontend architecture and our approach to state management and persistence.
I’ll begin chronologically with the project requirements, jumping back in time to the beginning of my involvement.
Requirements
When I joined the project, the team had already produced a few prototypes using p5, and was looking to combine them into a single web app with shared state.
One prototype was a flowchart UI for defining mathematical constructions:
The other was a 2-D plot UI for exploring complex values:
The team was looking to create a unified web application that combined both UIs and bound them to a shared state, so that users could build and explore their constructions across both interfaces simultaneously.
In terms of requirements, the Flowchart UI, Plot UI and Solver would all need to read from and write to the constraint graph simultaneously. We also needed to find a way to save and load entire constraint graphs, including their internal numeric values. All of this would be deployed together as a fast and beautiful web app.
Choosing boring tools
To optimize for stability and maintainability, we chose the current most boring and popular web-framework combo for interactive apps: React + NextJS. Switching to React also allowed us to use the React Flow library, an excellent library for creating interactive flowcharts. React Flow is a mature library (used by the likes of Typeform) that has good performance and enables many intuitive user interactions that would be a massive time cost to implement otherwise. It’s also the library I chose for InstructionKit’s flowchart editor, so I had experience with its API.
Spanning state between React and p5 via proxies
Once we knew we were using React and rewriting the Flowchart UI with React Flow, we had to decide how to integrate the Plot UI into the app. We considered rewriting that UI in React as well, but I did some research and found a library for embedding p5 into React which would allow us to reuse the existing Plot UI and therefore iterate more quickly.
However, getting the p5 code to share state with the rest of the application was a challenge, since p5 uses global variables to store state. My solution was to make the existing global state variable a Valtio proxy, allowing the React parts of the application to subscribe to changes in that global variable (see code).
In the process I migrated all of the constraint-solver class systems and the Plot UI to use ES6 module imports and typescript. It was a big commit but AI code-completion made the migration relatively painless.
Note: A Pitfall of Nested Proxy State
When you add a new object to a Valtio proxy object, Valtio copies your object in order to store it as a proxy. This means the object you added is no longer identical to the object when accessed through its parent in the proxy store. You might mutate your original object, assuming that you are mutating the object stored in state, when in fact you are not.
Here’s a line of code we needed to write to address that problem. Be warned! Proxies are weird and hidden and can break your mental model of how Javascript works!
Saving and Loading Constructions to the User’s Filesystem
We evaluated a few possibilities for persisting constructions. One option was to set up a backend and a database, but that would be more work than setting up a local-first storage option. We discussed three storage options:
Using browser localstorage API
Using URL encoding
Saving and loading from user’s filesystem
At first I set up URL encoding, which is great for allowing people to quickly share the entire state of their application with each other by just pasting their current URL. However, we started running into problems with browser compatibility and URL length, so I switched to enabling save-to/load-from the user’s filesystem.
Saving to the user’s filesystem is great because the user’s filesystem becomes an extension of your product UI; the user can save files, make folders and backups and versions, send them over email, etc., all of which are affordances that I can skip over as a developer (which is not true of the browser-localstorage approach).
I was able to set this up quickly since I built something similar for my open-source human-programming tool Methodable.
I did need to do some extra work however, since persisting constructions required me to set up serialization for our class-based application state.
Serializing JavaScript classes
I generally recommend using plain JavaScript objects for application state rather than classes. There are a few reasons I prefer that pattern, one of which is that serializing and deserializing plain JavaScript objects is as easy as JSON.stringify()
and JSON.parse()
. However, I joined this project when the class-based model system was already pretty mature, so instead I set up serializers and deserializers for all of the classes.
I created Zod schemas for each class, such that deserialized graphs could be parsed and validated before being loaded into the application state (example of a schema). Then, I wrote a serialize method for each class, such that the entire graph could be serialized (example of a serialize method).
Note: We serialize everything, including the values within each Vertex, because the state of values are path-dependent. Consider the following:
We’ve created a system that solves `x^2 = 4`, and has reached the equilibrium where x = -2. We can actually reach the other equilibrium where x = 2 by winding our free variable (the product) around the origin of the complex plane and returning it to its current value, 4. So, the value of bound variables is dependent not only on the current values of free variables, but also on the path those free variables took to arrive at their current value. For this reason, we serialize everything!
To-do: clean up the deserialization/instantiation flow
Due to the nuances and unevenness of the way state is stored in the graph’s class hierarchies, deserialization is currently done in one gigantic function. In the future, I would standardize all class constructors to take in a serialized state as their single argument. With that refactor in place, we could gradually deserialize a graph using each class’s constructor, rather than having to do it all in a single messy function.
Up Next
I hope you enjoyed learning about the tooling and technical architecture of the Digital Abacus Tool. Feel free to subscribe for more on the Digital Abacus, and other updates from Human-Programming land.
Best,
Daniel Sosebee