Desktop-like SPA

by Eric Fortis

Uxtely is a single-page application that:

For continuously saving, there are three general options:

  • Option 1: Collecting how to reproduce each action.
  • Option 2: Saving a complete snapshot after every action.
  • Option 3: A hybrid, such as saving on immutable data structures, for instance, Immutable.js.

We'll discuss the first one as it's the most versatile and simple.

Reproducing Actions

Let's call Undo Frame to the data needed for replaying an action. For example, the following frame updates the title field of a Card with a certain id.

    [BASE.setCard, 'idABC', CF.title, 'The New Title']

This way undo is a matter of reverting to a previous snapshot, and replaying each change up to the penultimate.

Some undo steps have many actions. For example, moving an Entry to another Card.

Uxtely Moving an Entry to Another Card

Behind the scenes that function has a removeEntry('cardA', …) and an insertEntry('cardB', …). Therefore, we use a transaction to collect all the calls to the underlying BASE setters into a single undo frame.

function moveEntryToAnotherCard(fromCardId, entryId, toCardId) {
    const endTx = TransactionRecorder();
    // …
    _removeEntry(fromCardId, entryId);
    _insertEntry(toCardId, …);


There's an important part missing in the previous snippet. How to copy the properties of the removed Entry?

In contrast to an immutable data structure (Option 3), we can't simply edit non-primitives, such as objects and arrays. Instead, we have to create a new one. Otherwise, the edit would inadvertently change them in the undo stack as well.

For example, outputLinks is an array of objects (Entry Pointers). So to move an Entry to another Card, we have to deep clone that array, and assign it to the newly inserted Entry.

Uxtely an Entry with many output links to share a UI constraint


The undo frames could also be used for synchronizing multiple users in real-time (not implemented in Uxtely). Also, they're extendable with metadata. For example, for squashing many undo steps into one, such as a burst of a slider input.

Uxtely Fixed Decimals slider input field

Data Flow

The Backend is not to be confused with the server-side. In this diagram, everything is client-side.

localStorage File System IndexedDB UI States Memory Frontend Compress Decompress Sanitize File Data Memory Backend API Transactions Engine Memory Middleware


These are the UI components, for example:

  • card/Card.js
  • toolbar/PreviewButton.js

The frontend memory objects, UI States, are non-undoable actions, for example whichMenuIsOpen or whatIsSelected. Some of them are persisted to the browser's localStorage, like toolbarIsVisible and scrollbarsAreVisible.

Uxtely UI States Showing Edit Menu and Multi-selection of Entries


React-wise, the state-controlled components need the static getDerivedStateFromProps(props, state) method in order to restore a previous value when undoing, and for real-time updates.

Middleware (Business Layer)

The middleware files are the "app-specific" algorithms. I think of them as the engine, or as the fun algorithms to write. The rule is that they don't query or need the UI; they're are exclusively fed from the backend. By convention, their extension is *.mid.js. Here are some examples:


It's used for drawing the connections, and for finding the Entries within a marquee region.

Uxtely Marquee Selecting Entries


Determines the dependency order for computing formulas and evaluating user-JavaScript. For example, Nested Cards are like parentheses, so the deepest ones get computed first.

Uxtely Showing Computing Graph Hierarchy


The loop detection algorithms prevent creating connections that would cause infinite loops. For example, when trying to link a total that depends on another Card into that Card.

Uxtely Showing how infinite loops get prevented


The middleware engine has some memory objects too. For example, for the connector points, and caching payload relevant fields for Cards with JavaScript code.

Backend API

The backend memory contains the end-user File. Its data is JSON-safe, and it's saved automatically in the browser's IndexedDB after every change (Option 2). Therefore, if there are many tabs with the same file open, the one on the last edited tab wins. Also, the corresponding undo stack is saved there too, so it can be restored across sessions for unlimited undo levels.

Although Uxtely doesn't save to the server-side, that wouldn't be too different from saving to the local file system.

By convention, these files are under **/api directories. For example:

  • app/api/BaseSetters.js
  • card/api/CardGetters.js
  • card/api/internal/_updateCardTitle.js


The compressor is mainly for removing the default Card and Entry fields. You can see that compressed JSON file by clicking File → Save.


To prevent prototype pollution attacks, all the constructors (File, Card, Entry) are instantiated with their defaults, and sealed with Object.seal, before assigning or merging new properties.

Type Checks

Example 1: isCard(cardId) besides type-checking, it validates that the Card exists.

function _updateCardTitle(cardId, title) {
  setCard(cardId, CF.title, title);

Example 2: The number of decimal digits validator verifies that it's in range [0-15], in addition to the integer check.


I hope this guide helps you to organize and wire up your SPA. Don't forget to check out Uxtely. It's free free.uxtely.com

Sponsored by: