Gooey

13 min read Original article ↗

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 building blocks:

  • field(val) objects hold single values
  • collection([1,2,3]) arrays hold lists of data
  • calc(() => ...) functions represent calculations: dynamic cells of data that automatically recalculate when their dependencies change
  • model({ key: val }) objects hold named key-value mappings
  • dict([[key1, val1], [key2, val2]]) mappings hold arbitrary key-value mappings

Place your calculations directly to the DOM:


Flexible

Define custom elements to add dynamic functionality onto plain old HTML documents:

tsx

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:

tsx

import Gooey, {
  VERSION,
  Component,
  Collection,
  Model,
  mount,
  model,
  collection,
  calc,
  flush,
} from '@srhazi/gooey';

const allMeasurements = [
  { name: '1:add', count: 10000 },
  { name: '2:update-all', count: 10000 },
  { name: '3:update-some', count: 10 },
  { name: '4:insert-some', count: 10 },
  { name: '5:delete-some', count: 10 },
  { name: '6:clear', count: 10000 },
];

function getMedian(count: number, times: number[]) {
  if (times.length === 0) {
    return 'N/A';
  }
  const sorted = [...times].sort((a, b) => a - b);
  const left = sorted[Math.floor((sorted.length - 1) / 2)];
  const right = sorted[Math.ceil((sorted.length - 1) / 2)];
  const median = (left + right) / 2;
  const itemsPerMs = count / median;
  return `${median.toFixed(2)}ms (${itemsPerMs.toFixed(2)} items/ms)`;
}

const measurements: Record<string, Collection<number>> = {
  '1:add': collection<number>([]),
  '2:update-all': collection<number>([]),
  '3:update-some': collection<number>([]),
  '4:insert-some': collection<number>([]),
  '5:delete-some': collection<number>([]),
  '6:clear': collection<number>([]),
};

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[name].push(time);
  };
};

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 insertSomeItems = measure('4:insert-some', () => {
    for (let i = 0; i < 10; ++i) {
      items.splice(Math.floor(Math.random() * items.length), 0, {
        val: itemId++,
      });
    }
  });

  const deleteSomeItems = measure('5:delete-some', () => {
    if (items.length === 0) return;
    for (let i = 0; i < 10; ++i) {
      items.splice(Math.floor(Math.random() * items.length), 1);
    }
  });

  const clearItems = measure('6: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 items
        </button>
        <button data-gooey-insert-some on:click={insertSomeItems}>
          Insert 10 items
        </button>
        <button data-gooey-delete-some on:click={deleteSomeItems}>
          Delete 10 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>
        {allMeasurements.map(({ name, count }) => (
          <li>
            {name}:{' '}
            {calc(() => (
              <>
                {measurements[name].length} runs; median time:{' '}
                {getMedian(count, measurements[name])}
              </>
            ))}
          </li>
        ))}
      </ul>
    </div>
  );
};

mount(document.body, <Benchmark />);

tsx

import React, { useRef, useMemo, useCallback, useState } from 'react';
import * as ReactDOM from 'react-dom';
import * as ReactDOMClient from 'react-dom/client';

const allMeasurements = [
  { name: '1:add', count: 10000 },
  { name: '2:update-all', count: 10000 },
  { name: '3:update-some', count: 10 },
  { name: '4:insert-some', count: 10 },
  { name: '5:delete-some', count: 10 },
  { name: '6:clear', count: 10000 },
];

function getMedian(count: number, times: number[]) {
  if (times.length === 0) {
    return 'N/A';
  }
  const sorted = [...times].sort((a, b) => a - b);
  const left = sorted[Math.floor((sorted.length - 1) / 2)];
  const right = sorted[Math.ceil((sorted.length - 1) / 2)];
  const median = (left + right) / 2;
  const itemsPerMs = count / median;
  return `${median.toFixed(2)}ms (${itemsPerMs.toFixed(2)} items/ms)`;
}

const Benchmark = () => {
  const [items, setItems] = useState<{ key: number; val: number }[]>([]);
  const [measurements, setMeasurements] = useState<Record<string, number[]>>({
    '1:add': [],
    '2:update-all': [],
    '3:update-some': [],
    '4:insert-some': [],
    '5:delete-some': [],
    '6:clear': [],
  });
  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,
          [name]: [...measurements[name], time],
        }));
      };
    },
    [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 insertSomeItems = useMemo(
    () =>
      measure('4:insert-some', () => {
        const newItems = items.slice();
        // Randomly add insert 10 new items to the 10k array
        for (let i = 0; i < 10; ++i) {
          const id = itemId.current++;
          newItems.splice(Math.floor(Math.random() * newItems.length), 0, {
            key: id,
            val: id,
          });
        }
        setItems(newItems);
      }),
    [items, setItems, measure]
  );

  const deleteSomeItems = useMemo(
    () =>
      measure('5:delete-some', () => {
        const newItems = items.slice();
        // Randomly delete 10 new items from the 10k array
        for (let i = 0; i < 10; ++i) {
          newItems.splice(Math.floor(Math.random() * newItems.length), 1);
        }
        setItems(newItems);
      }),
    [items, setItems, measure]
  );

  const clearItems = useMemo(
    () =>
      measure('6: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 items
        </button>
        <button data-react-insert-some onClick={insertSomeItems}>
          Insert 10 items
        </button>
        <button data-react-delete-some onClick={deleteSomeItems}>
          Delete 10 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>
        {allMeasurements.map(({ name, count }) => (
          <li key={name}>
            {name}: {measurements[name].length} runs; median time:{' '}
            {getMedian(count, measurements[name])}
          </li>
        ))}
      </ul>
    </div>
  );
};

const root = ReactDOMClient.createRoot(
  document.body
);
root.render(<Benchmark />);

As the numbers show, React is pretty good at large changes to a large application all at once, but is slow to apply small changes to a large application. Gooey is very good at initial renders and really excels at applying precision updates to an existing application.

ReactGooeyComparison factor
Add 10k items206.85ms
(48.34 items/ms)
91.50ms
(109.29 items/ms)
Gooey is 2.26x faster
Update 10k items11.55ms
(865.80 items/ms)
77.05ms
(129.79 items/ms)
React is 6.67x faster
Update 10 random items3.80ms
(2.63 items/ms)
0.50ms
(20.00 items/ms)
Gooey is 7.60x faster
Insert 10 random items4.85ms
(2.06 items/ms)
3.10ms
(3.23 items/ms)
Gooey is 1.57x faster
Delete 10 random items4.80ms
(2.08 items/ms)
3.10ms
(3.23 items/ms)
Gooey is 1.55x faster
Clear 10k items34.40ms
(290.70 items/ms)
45.70ms
(218.82 items/ms)
React is 1.33x faster
Gooey v0.22.0 vs React 19.1.0; Median run over 100 runs on Chrome 135.0.7049.96
Benchmark performed on a MacBook Pro (16-inch, 2019)

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.

Gooey is designed to be very fast at initial rendering and fast to apply precision updates to small state changes, which are the most common places where speed really matters.


Bleeding edge

Should you use this? Maybe!

It has a robust suite of tests, but is not at version 1.0. It is unlikely to change significantly to get to version 1.0.

If you want to help, reach out!

The author can be reached via email, check https://abstract.properties.


News

- v0.24.1

Version 0.24.1 (npm, git, github) has been released.

Minor feature release.

Changes since 0.24.0:

  • FEATURE: new dyn() convenience function actually exported

- v0.24.0

Version 0.24.0 (npm, git, github) has been released.

Support for Hot Module Replacement, bugfixes.

Changes since 0.23.0:

  • BREAKING: mount() now only works if the target nod has no child nodes; if two mount() nodes are called to the same element, the first mount will automatically be unmounted
  • FEATURE: new hotSwap() method, allowing for hot swapping module exports
  • FEATURE: new @srhazi/gooey-vite-plugin package, which supports hot module replacement using vite
  • BUGFIX: JSX.ElementType is used, allowing for full async component support with TypeScript, no optional chaining needed.

- v0.23.0

Version 0.23.0 (npm, git, github) has been released.

Bugfixes and typecheck fixes

Changes since 0.22.0:

  • FEATURE: new dynMap() and dyn() convenience functions exported
  • BREAKING: JSX.Element now returns Partial<RenderNode>, which means if you were calling any RenderNode methods on JSX, you must now call them with optional chaining (i.e.: jsx.retain?.()). See https://github.com/microsoft/TypeScript/issues/61620
  • BUGFIX: fixed crashes when calculations had changes in their dependencies and were retained/unretained/destroyed in certain sequences

Migration guide:

If you call any RenderNode methods on evaluated JSX, change these to be optional chaining calls. For example: jsx.retain()jsx.retain?.().

This is an unfortunate runtime fix for a typecheck problem. See this TypeScript issue for more information: https://github.com/microsoft/TypeScript/issues/61620.

- v0.22.0

Version 0.22.0 (npm, git, github) has been released.

Note: Version 0.21.0 and 0.21.1 were not published to NPM.

This version includes a major rewrite of the data layer. It significantly improves the performance of changing items in a collection.

Gooey is approaching version 1.0, which I will consider to be the “done” version. That is to say, the version at which major breaking changes are not performed, and development is solely focused on bugfixes, performance optimizations, and exposing engine internals for better debugging tools.

Changes from 0.20.0:

  • FEATURE: Promises may now be rendered directly as JSX, rendering to their resolved values. Rejected promises are errors. As a result, async components Just Work™.
  • BREAKING: Dict has adjusted some methods: .entries(), .keys(), and .values() return plain old arrays. A new .keysView() returns a view of the keys.
  • BREAKING: model.subscribe(), collection.subscribe(), and dict.subscribe() all synchronously call the callback on first invocation with their current fields, allowing subscribers to know the contents of the model/collection/dict without having to reach around and read the contents before subscribing.
  • BREAKING: model.field() returns a DynamicMut, not a Field
  • PERFORMANCE: Improved performance for sparse insert/deletes
  • PERFORMANCE: Improved render performance for large arrays
  • BUGFIX: Fixed very subtle infinite loop / hanging cycle bug
  • BUGFIX: Fixed crash when calculation released at the same time as a subscription
  • BUGFIX: Fixed potential issues caused by views that are derived from collections that have .moveSlice() followed by .splice() in certain sequences

- 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 the value prop
  • BUGFIX: Improve JSX event handling types (added several)
  • NEW: isDynamic and isDynamicMut exported
  • BUGFIX: dynGet, dynSet, and dynSubscribe have more permissive types
  • NEW: new dynMap function
  • NEW: .map<V>(fn: (val: T) => V): Calculation<V> exists on Calculation<T> and Field<T>
  • NEW: export DynamicSubscriptionHandler and DynamicNonErrorSubscriptionHandler
  • BUGFIX: support the popover attribute
  • NEW: The DynMut, Dynamic, and DynamicMut 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 and field.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; so ref("hi") now returns a Ref<string>, not Ref<string | undefined>
  • BREAKING: the Ref<T> class is now invariant on T
  • 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 called Dict.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 to dict/Dict
  • NEW: new dynGet, dynSet, dynSubscribe helper functions, and Dyn 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