Gooey
A focused and flexible frontend web framework.
Works like a spreadsheet for UI: build cells of data or UI that may read other cells and Gooey makes sure everything is always up to date.
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 like) with JSX.
Straightforward
Build your application state with simple building blocks:
field(val)
objects hold single valuesmodel({ key: val })
objects hold named key-value mappingsdict([[key1, val1], [key2, val2]])
mappings hold arbitrary key-value mappingscollection([1,2,3])
arrays hold lists of datacalc(() => ...)
functions represent calculations: dynamic cells of data that automatically recalculate when their dependencies change
Place your calculations directly to the DOM:
Flexible
Define custom elements to add dynamic functionality onto plain old HTML documents:
import Gooey, { defineCustomElement, model, calc, dynGet } from '@srhazi/gooey';
document.body.innerHTML = `<p><my-greeting>World</my-greeting></p>`;
defineCustomElement({
tagName: 'my-greeting',
Component: ({ children }) => {
const state = model({ clicks: 0 });
return (
<button on:click={() => (state.clicks += 1)}>
Hello, {children}
{calc(() => '!'.repeat(state.clicks))}
</button>
);
},
});
Unique
There are a few features that make Gooey stand apart from other frameworks:
- 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 decently fast!
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 the common act of updating a small amount of data in a large application is very fast.
Here’s a crude benchmark to demonstrate rendering / updating 10k items:
import Gooey, {
VERSION,
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>Gooey version {VERSION}</p>
<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>
React version {React.version}; ReactDOM version {ReactDOM.version}
</p>
<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 pretty good at bulk update, but Gooey really excels at applying precision updates to an existing application.
React | Gooey | Comparison factor | |
---|---|---|---|
Add 10k items | 205.60ms (48.64 items/ms) | 147.80ms (67.66 items/ms) | Gooey is 1.39x faster |
Update 10k items | 13.80ms (724.64 items/ms) | 63.20ms (158.23 items/ms) | React is 4.58x faster |
Update 10 random items | 4.40ms (2.27 items/ms) | 0.60ms (16.67 items/ms) | Gooey is 7.34x faster |
Clear 10k items | 29.20ms (342.46 items/ms) | 57.80ms (173.01 items/ms) | React is 1.98x faster | Gooey v0.20.0 vs React 19.0.0; Median run over 100 runs on Chrome 134.0.6998.89 Benchmark performed on a MacBook Pro (16-inch, 2019) |
To be honest, React is fast at updates, but slow at initial rendering. It really suffers when applications get large and small state changes cause big re-renders.
On the other hand, Gooey is designed to be very fast at initial rendering and in the presence of small state changes, which are the most common places where speed really matters.
Bleeding edge
Should you use this? Maybe!
While it has a robust set of tests, 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.20.0
Version 0.20.0 (npm, git, github) has been released.
Changes from 0.19.1:
- FEATURE: The rendering layer now uses the new moveBefore DOM method when binding data to the DOM. Now sorting, moving, and other relocations of DOM nodes can be performed without losing CSS transform state, video play status, and other transient state. Note: as of 2025-03-17, this method is only implemented in Chromium based browsers, but has support in Firefox and WebKit. When unavailable, insertBefore is used instead, which does not preserve all transient state.
- v0.19.0
Version 0.19.1 (npm, git, github) has been released.
Changes from 0.19.0:
- BUGFIX: the
property
attribute on the<meta>
tag now correctly typechecks
- v0.19.0
Version 0.19.0 (npm, git, github) has been released.
Changes from 0.18.3
- BUGFIX: previously, calculations created within other calculations/components were automatically retained, which could lead to surprising side effects. Now, only calculations that are actively bound to the DOM / subscribed to are automatically retained.
- v0.18.3
Version 0.18.3 (npm, git, github) has been released.
Changes from 0.18.0
- BUGFIX:
<textarea>
supports thevalue
prop - BUGFIX: Improve JSX event handling types (added several)
- NEW:
isDynamic
andisDynamicMut
exported - BUGFIX:
dynGet
,dynSet
, anddynSubscribe
have more permissive types - NEW: new
dynMap
function - NEW:
.map<V>(fn: (val: T) => V): Calculation<V>
exists onCalculation<T>
andField<T>
- NEW: export
DynamicSubscriptionHandler
andDynamicNonErrorSubscriptionHandler
- BUGFIX: support the
popover
attribute - NEW: The
DynMut
,Dynamic
, andDynamicMut
types are exported - BUGFIX -
@srhazi/gooey
can now be imported in an environment that does not have browser globals (i.e. from within node.js)
- v0.18.0
Version 0.18.0 (npm, git, github) has been released.
Changes from 0.17.3
- BUGFIX - fix issue where
Dictionary#keys
did not always produce subscription changes - BUGFIX - fix issue where DOM updates did not always get applied correctly
- BUGFIX - fix issue where calculations were unnecessarily run in certain situations
- BREAKING -
calculation.subscribe
,field.subscribe
now share the same function callback taking(error: Error | undefined, value: undefined | T) => void;
- BREAKING -
calculation.subscribe
andfield.subscribe
now call callback once synchronously when called - BREAKING -
calculation.subscribeWithError
removed - BUGFIX - subscription retain during the lifetime of the subscription
- BREAKING [internal] - switched from yarn to npm
- CHANGE - large refactor to
RenderNode
internals - CHANGE - improved bench2 benchmark
- v0.17.3
Version 0.17.3 (npm, git, github) has been released. It is a minor bugfix release.
Changes from v0.17.2
- BUGFIX: An error is no longer thrown (and an event handler is not added) if
undefined
is explicitly passed as an event handler to an intrinsic element.
- v0.17.2
Version 0.17.2 (npm, git, github) has been released. It has some major breaking changes.
Note: Version 0.17.0 and 0.17.1 were not published to NPM and should be avoided.
Changes from v0.16.0
- BREAKING:
Ref<T>
does not imply.current: T | undefined
; soref("hi")
now returns aRef<string>
, notRef<string | undefined>
- BREAKING: the
Ref<T>
class is now invariant onT
- FEATURE: defineCustomElement(options) allows you to define custom elements, both with or without a shadow root
- FEATURE: CustomElements interface exported to allow for specifying the type of custom elements
- FEATURE: mount(shadowRoot, jsx) now works on shadow roots
- 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