FA Framework

Charlie Olson
Fenster Analytics
Published in
7 min readJun 12, 2023

--

“Why not just use React?”

This question comes up now-and-then, and the simple answer is: because I just don’t like it. The entire React system feels hacky and wrong. It rankles my sensibilities to see presentation in the logic, and logic in the presentation. Declaring classes as functions in 2023? Barf. Boilerplate “useState” magic just to create a dynamic member variable? Dry heave. A system focused on DOM generation rather than manipulation, which intrinsically necessitates reconciliation? Deal-breaker. The list could go on, we could make conjectures about development velocity, long-term maintenance, and the advantages of having complete control over our framework, but the point is: we’re not going to use React.

So, if not React, what have we been using?

FA Framework

At Fenster Analytics, we have a custom “framework” built on Web Components — which is standard javascript. FA is not a heavyweight framework-framework like React; it’s mostly just glue to handle value propagation, state management, user interaction, form logic, template rendering, etc.

I wouldn’t say the FA Framework is trivial, but it’s also only a couple thousand lines of code. Web Components and Handlebars do most of the heavy lifting.

Let’s jump directly into an example. We’ll implement the equivalent of this example from the official React.dev.

Example in React

For reference, the final React example code is this abomination:

import { useState } from 'react';

function FilterableProductTable({ products }) {
const [filterText, setFilterText] = useState('');
const [inStockOnly, setInStockOnly] = useState(false);

return (
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly}
onFilterTextChange={setFilterText}
onInStockOnlyChange={setInStockOnly} />
<ProductTable
products={products}
filterText={filterText}
inStockOnly={inStockOnly} />
</div>
);
}

function ProductCategoryRow({ category }) {
return (
<tr>
<th colSpan="2">
{category}
</th>
</tr>
);
}

function ProductRow({ product }) {
const name = product.stocked ? product.name :
<span style={{ color: 'red' }}>
{product.name}
</span>;

return (
<tr>
<td>{name}</td>
<td>{product.price}</td>
</tr>
);
}

function ProductTable({ products, filterText, inStockOnly }) {
const rows = [];
let lastCategory = null;

products.forEach((product) => {
if (
product.name.toLowerCase().indexOf(
filterText.toLowerCase()
) === -1
) {
return;
}
if (inStockOnly && !product.stocked) {
return;
}
if (product.category !== lastCategory) {
rows.push(
<ProductCategoryRow
category={product.category}
key={product.category} />
);
}
rows.push(
<ProductRow
product={product}
key={product.name} />
);
lastCategory = product.category;
});

return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
);
}

function SearchBar({
filterText,
inStockOnly,
onFilterTextChange,
onInStockOnlyChange
}) {
return (
<form>
<input
type="text"
value={filterText} placeholder="Search..."
onChange={(e) => onFilterTextChange(e.target.value)} />
<label>
<input
type="checkbox"
checked={inStockOnly}
onChange={(e) => onInStockOnlyChange(e.target.checked)} />
{' '}
Only show products in stock
</label>
</form>
);
}

const PRODUCTS = [
{category: "Fruits", price: "$1", stocked: true, name: "Apple"},
{category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
{category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
{category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
{category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
{category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
];

export default function App() {
return <FilterableProductTable products={PRODUCTS} />;
}

What is wrong with this React example?

For one thing, the presentation layer is inseparable from the logic. It’s atrocious orthogonality. A UI designer or engineer cannot work on any of the elements here in isolation — they need to know what the entire system is supposed to produce (a table) in order to modify any sub-component. These inter-dependencies defeat the purpose of breaking a system into smaller components, which is to reduce complexity into more manageable, understandable pieces of work.

If we want to change ProductTable’s presentation to use something other than a <table> element, we have to know that the ProductCategoryRow and ProductRow functions also need to be changed, even though we haven’t changed any logic and this should only be a cosmetic change in a well-designed system.

There’s more to dissect, but let’s move on.

Example in FA Framework

Implementing the same example in FA Framework leads to two files:

  1. example.mjs
  2. example.html

The mjs file contains all of the logic for manipulating the data. The html file contains the template for presenting the data. The resulting <fa-docs-example> element looks like this:

<fa-docs-example>
Ignore the controversial classifications of pumpkins and peas.

example.mjs:

import {Element} from "./element.mjs";
import * as util from "./util.mjs";

const sampleData = [
{ category: "Fruits", price: "$1", stocked: true, name: "Apple" },
{ category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit" },
{ category: "Fruits", price: "$2", stocked: false, name: "Passionfruit" },
{ category: "Vegetables", price: "$2", stocked: true, name: "Spinach" },
{ category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin" },
{ category: "Vegetables", price: "$1", stocked: true, name: "Peas" }
];

export class Example extends Element {
constructor() {
// "docs_example" will render using the template: docs/example.html
super('docs_example');

this._productList = sampleData;
this._updateList();
}

onFilterTextChange(el) {
this._filterStr = el.value;
this._updateList();
}

onInStockOnlyChange(el) {
// getElementValue converts checkbox value to true/false
this._inStockOnly = util.getElementValue(el);
this._updateList();
}

_updateList() {
// Assemble a categoryDict to pass to the template for rendering
const categoryDict = {};
for (const product of this._productList) {
// Skip out-of-stock products
if (this._inStockOnly && !product.stocked) {
continue;
}

// Skip names that don't pass the filter text
if (this._filterStr) {
if (!product.name?.toLowerCase().includes(this._filterStr)) {
continue;
}
}

// This logic generally happens in code
// (rather than in presentation)
product.displayClass = product.stocked ? 'in-stock' : 'not-in-stock';

// Get or create the list for products in this category
const category = util.getOrSetPathValue(categoryDict, product.category, Array);
category.push(product);
}

// Re-render the dynamic elements
this.dynamic = {'categoryDict': categoryDict};
}
}
window.customElements.define('fa-docs-example', Example);

docs/example.html:

<style>
:host {
box-sizing: border-box;
display: inline-block;
border: solid 2px blue;
padding: 1em;
}
filter-panel {
display: flex;
flex-direction: column;
}
result-panel {
display: flex;
flex-direction: column;
gap: 0.5em;
}
product-list {
display: grid;
grid-template-columns: auto auto;
}
header-row {
display: contents;
color: yellow;
font-weight: bold;
}
.not-in-stock {
color: red;
}
</style>
<filter-panel>
<input placeholder="Search..." keyup-function="filterName" />
<label>
<input type="checkbox" change-function="filterStock" />
Only show products in stock
</label>
</filter-panel>
<result-panel data-fa-bind-template="dynamic">
\{{#each categoryDict}}
<category-detail>
<h1>\{{@key}}</h1>
<product-list>
<header-row>
<div>Name</div>
<div>Price</div>
</header-row>
\{{#each this}}
<div class="\{{displayClass}}">
\{{name}}
</div>
<div>
\{{price}}
</div>
\{{/each}}
</product-list>
</category-detail>
\{{/each}}
</result-panel>

Note: the forward slash (\) before each of the Handlebars tags is necessary within data-fa-bind-template blocks if the tag should be re-evaluated when data changes. The template block is itself a template. So, without the forward slash, the tag would only get evaluated once — with whatever data the element had at the time it was first rendered.

Why is the FA Framework version better than the React version?

In comparison to the main criticism earlier, now there is a clean separation between logic and presentation. The code produces data, which is then rendered by the template engine. Both sides care about how that output data is structured, but the UI doesn’t need to know how the data was made, and the code doesn’t need to know exactly how the UI will present it.

This orthogonality, or separation of concerns, is how it should be. Cosmetic changes are contained within the HTML template. Logic changes can be tested against data directly, rather than inspecting fully-rendered HTML.

This could still be better, if there was validation of the data structure passed to the template, but that’s for future improvements. In the meantime, designers are able to inspect raw data during HTML development with simple template helpers.

ElementStateMachine

Expanding on the previous example, Elements typically progress through multiple states. A typical editor-element might go through stages like:

  1. Request data from API (no saving allowed yet) — show loading spinner
  2. Render form data (editing and saving allowed) — show form editor
  3. Save form data to API — disable form editor
  4. Load updated data (goto step 2)

One option is to track “state” (in the React sense) with a single variable, e.g. editorState. Actions can then check editorState to see if the action is allowed: the save command can’t execute while editorState === LOADING. This is slightly better than having no explicit state at all, but still pretty messy.

FA makes this simple with stateMachine elements. A more realistic version of the previous example would load data from the API first, then render the product summary:

import {ElementStateMachine} from "./element_state_machine.mjs";
import {ElementState} from "./element_state.mjs";
import * as util from "./util.mjs";

//
// 1. Load data from the API
//
class Load extends ElementState {
_init() {
// Optionally set the state's default template
// (simple loading spinner in this case)
this.template = 'fa_loading';

// States can be reused in other StateMachines though
// and templates can be overridden by the StateMachine config (below)
}

async _start() {
// Normally this data would be loaded from an API
this.shared.rawData = [
{ category: "Fruits", price: "$1", stocked: true, name: "Apple" },
{ category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit" },
{ category: "Fruits", price: "$2", stocked: false, name: "Passionfruit" },
{ category: "Vegetables", price: "$2", stocked: true, name: "Spinach" },
{ category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin" },
{ category: "Vegetables", price: "$1", stocked: true, name: "Peas" }
];

// When the API call completes, continue to the next state
this.goto('render');
}
}

//
// 2. Render the main template and enable functionality
//
class Render extends ElementState {
_init() {
this.template = 'docs_example';
}

async _start() {
this._productList = this.shared.rawData;
this._updateList();
}

filterName(el) {
this._filterStr = el.value;
this._updateList();
}

filterStock(el) {
this._inStockOnly = util.getElementValue(el);
this._updateList();
}

_updateList() {
const categoryDict = {};
for (const product of this._productList) {
if (this._inStockOnly && !product.stocked) {
continue;
}
if (this._filterStr) {
if (!product.name?.toLowerCase().includes(this._filterStr)) {
continue;
}
}
product.displayClass = product.stocked ? 'in-stock' : 'not-in-stock';

const category = util.getOrSetPathValue(categoryDict, product.category, Array);
category.push(product);
}
this.dynamic = {'categoryDict': categoryDict};
}
}

//
// StateMachine is the actual Element. It:
// * defines the state mapping
// * overrides templates if necessary
// * sets the initial activation
// (the 'load' state in this case)
//
export class ExampleStateMachine extends ElementStateMachine {
constructor() {
super({
'load': {
'active': true,
'class': Load,
'config': {
// Can override the default template:
//'template': 'fancy_loading_screen',
},
},
'render': {
'class': Render,
},
});
}
}
window.customElements.define('fa-docs-example-statemachine', ExampleStateMachine);

There’s More

But that’s all for now.

We don’t support the FA Framework outside of internal development, so if it appeals to you, consider working at Fenster Analytics.

--

--

Charlie Olson
Fenster Analytics

Charlie makes video games and analytics software.