Gooey
Gooey is a new frontend web framework that is focused and flexible.
It works like a spreadsheet: build cells of UI or data that can read other cells of data, and it takes the work of ensuring the data and UI is always up to date.
It helps deal with the complicated bits of the Model and View in the traditional Model-View-Controller architectural pattern: ensuring that the DOM and any derived data is up to date.
It’s inspired by build systems, spreadsheets, and Backbone.
Find out more about how it works and why it’s interesting.
Familiar
Build reusable Component
functions (or classes, if you prefer) with JSX.
import Gooey, { Component, mount, VERSION } from '@srhazi/gooey';
const Greet: Component<{ name: string; dir?: string }> = ({
name,
dir = 'left',
}) => {
return <marquee direction={dir}>Hello, {name}!</marquee>;
};
mount(
document.body,
<>
<Greet name="world" />
<Greet name={`Gooey ${VERSION}`} dir="right" />
</>
);
Straightforward
Application state is not coupled to UI, so there is no distinction between “component-local” state and “global” state.
Build your application state with simple building blocks, and bind them to the DOM:
model
objects hold key-value mappingscollection
arrays hold lists of datacalc
functions represent calculations: dynamic cells of data that automatically recalculate when their dependencies change
import Gooey, { mount, calc, model, collection } from '@srhazi/gooey';
const state = model({ count: 0 });
const clickTimes = collection<Date>([]);
mount(
document.body,
<>
<h2>Clicker Demo</h2>
<p>
<button
on:click={() => {
state.count += 1;
clickTimes.push(new Date());
// Let's not go overboard, limit to 5 items
if (clickTimes.length > 5) clickTimes.shift();
}}
>
Clicked {calc(() => state.count)} times
</button>
</p>
{calc(
() =>
clickTimes.length > 0 && (
<ul>
{clickTimes.mapView((date) => (
<li>Clicked at {date.toLocaleTimeString()}</li>
))}
</ul>
)
)}
</>
);
Unique
There are a few features that make Gooey pretty unique:
- Detached rendering allows JSX to be rendered and updated while detached, and even relocated to a different position in the DOM.
- IntrinsicObserver allows DOM elements rendered by JSX to observed and augmented, without knowing anything about the structure of the observed JSX.
Check out how these work in the guide.
Precise
Gooey is designed to make changes to UI quickly and easily.
While it’s not the fastest tool out there, it’s not terribly slow, and in certain operations, it’s quick!
Compared to React (which relies on virtual DOM diffing), Gooey is surgically precise: it knows what data relates to which pieces of UI and apply updates without needing to do all the work required by a virtual DOM diffing algorithm: render an entirely new tree and diff it from the prior one.
This has two benefits:
- No
key
prop needed: just write lists and they’ll be fast - No surprise slowness when nothing / one thing has changed
This also means that updating a few pieces of data amongst a much larger set of rendered data is very fast.
Here’s a crude benchmark to demonstrate rendering / updating 10k items:
import Gooey, {
Component,
Model,
mount,
model,
collection,
calc,
flush,
} from '@srhazi/gooey';
const measurements = collection<string>([]);
const measure = (name: string, fn: () => void) => {
return () => {
const start = performance.now();
fn();
flush();
const time = performance.now() - start;
console.log(`gooey ${name} duration`, time);
measurements.push(`gooey ${name} duration: ${time}ms`);
};
};
const Benchmark: Component = () => {
const items = collection<Model<{ val: number }>>([]);
let itemId = 0;
const addItems = measure('1:add', () => {
for (let i = 0; i < 10000; ++i) {
items.push(model({ val: itemId++ }));
}
});
const updateAllItems = measure('2:update-all', () => {
items.forEach((item) => (item.val *= 2));
});
const updateSomeItems = measure('3:update-some', () => {
if (items.length === 0) return;
for (let i = 0; i < 10; ++i) {
items[Math.floor(Math.random() * items.length)].val *= 2;
}
});
const clearItems = measure('4:clear', () => {
items.splice(0, items.length);
});
return (
<div>
<p>
<button data-gooey-add on:click={addItems}>
Add items
</button>
<button data-gooey-update-all on:click={updateAllItems}>
Update all items
</button>
<button data-gooey-update-some on:click={updateSomeItems}>
Update 10 random items
</button>
<button data-gooey-clear on:click={clearItems}>
Clear items
</button>
</p>
<ul class="bx by" style="height: 100px; overflow: auto; contain: strict">
{items.mapView((item) => (
<li>{calc(() => item.val)}</li>
))}
</ul>
<ul>
{measurements.mapView((measurement) => (
<li>{measurement}</li>
))}
</ul>
</div>
);
};
mount(document.body, <Benchmark />);
import React, { useRef, useMemo, useCallback, useState } from 'react';
import * as ReactDOM from 'react-dom';
import * as ReactDOMClient from 'react-dom/client';
const Benchmark = () => {
const [items, setItems] = useState<{ key: number; val: number }[]>([]);
const [measurements, setMeasurements] = useState<string[]>([]);
let itemId = useRef(0);
const measure = useCallback(
(name: string, fn: () => void) => {
return () => {
const start = performance.now();
ReactDOM.flushSync(() => {
fn();
});
const time = performance.now() - start;
console.log(`react ${name} duration`, time);
setMeasurements((measurements) => [
...measurements,
`react ${name} duration: ${time}ms`,
]);
};
},
[setMeasurements]
);
const addItems = useMemo(
() =>
measure('1:add', () => {
const newItems = items.slice();
for (let i = 0; i < 10000; ++i) {
newItems.push({
key: itemId.current,
val: itemId.current++,
});
}
setItems(newItems);
}),
[items, setItems, measure]
);
const updateAllItems = useMemo(
() =>
measure('2:update-all', () => {
setItems(items.map((item) => ({ key: item.key, val: item.val * 2 })));
}),
[items, setItems]
);
const updateSomeItems = useMemo(
() =>
measure('3:update-some', () => {
const newItems = items.slice();
for (let i = 0; i < 10; ++i) {
newItems[Math.floor(Math.random() * newItems.length)].val *= 2;
}
setItems(newItems);
}),
[items, setItems, measure]
);
const clearItems = useMemo(
() =>
measure('4:clear', () => {
setItems([]);
}),
[setItems, measure]
);
return (
<div>
<p>
<button data-react-add onClick={addItems}>
Add items
</button>
<button data-react-update-all onClick={updateAllItems}>
Update all items
</button>
<button data-react-update-some onClick={updateSomeItems}>
Update 10 random items
</button>
<button data-react-clear onClick={clearItems}>
Clear items
</button>
</p>
<ul
className="bx by"
style={{ height: '100px', overflow: 'auto', contain: 'strict' }}
>
{items.map((item) => (
<li key={item.key}>{item.val}</li>
))}
</ul>
<ul>
{measurements.map((measurement, i) => (
<li key={i}>{measurement}</li>
))}
</ul>
</div>
);
};
const root = ReactDOMClient.createRoot(
document.body
);
root.render(<Benchmark />);
As the numbers show, React is a bit faster than Gooey at creating elements and much faster at updating and destroying a whole bunch of elements. However, Gooey really excels at applying precision updates to an existing application.
React | Gooey | React vs Gooey | |
---|---|---|---|
Add 10k items | 225.28ms (44.38 items/ms) | 177.34ms (56.39 items/ms) | 127.06% Much faster |
Update 10k items | 15.75ms (635.08 items/ms) | 69.55ms (143.78 items/ms) | 22.65% Much slower |
Update 10 random items | 7.56ms (1.32 items/ms) | 1.89ms (5.29 items/ms) | 405.83% Wow! Way faster! |
Clear 10k items | 37.18ms (268.96 items/ms) | 60.57ms (165.10 items/ms) | 61.40% Much slower | Gooey v0.16.0 vs React 18.2.0; Median run over 100 runs on Chrome 117.0.5938.132; on a MacBook Pro (16-inch, 2019) |
To be honest, the poor performance of bulk update / empty isn’t that concerning. The majority of the time spent on Gooey has been focused on ensuring rendering & precision updates are fast and correct.
Bleeding edge
Should you use this? Maybe!
While it has a robust set of tests and is mostly stable, it’s still bleeding edge, so unless you want to help contribute, it’s probably best to think of this as a set of possibilities.
If you want to help, reach out!
The author can be reached via email, check https://abstract.properties.
News
- v0.16.0
Version 0.16.0 (npm, git, github) has been released. It has some major breaking changes.
Changes from v0.15.0
- NEW: new export:
debugGetGraph()
which returns the raw global dependency graph - BUGFIX: Fixed
TrackedMap.get
return type (now calledDict.get
) - BREAKING: Calculations changed from being a function-like object to being classes with a
.get()
method - BUGFIX: Fixed infinite commit render loop in certain circumstances, when
on:focus
/on:blur
used to changed sibling nodes, causing lost focus - BREAKING:
map
/TrackedMap
renamed todict
/Dict
- NEW: new
dynGet
,dynSet
,dynSubscribe
helper functions, andDyn
helper type, which allow for passing in Calc, Field, or raw values as props to components - BREAKING: Calculation
.subscribe()
changed to only show successful values;.subscribeWithError()
introduced to handle error and value subscription - BUGFIX: field objects are automatically retained when subscribed to