Data Types

Gooey has a set of core authoritative data types for application data:

  • A model is created via model() and behaves like a plain old JavaScript Object holding a fixed set of keys
  • A dict is created via dict() and behaves like a plain old JavaScript Map
  • A collection is created via collection() and behaves just like a plain old JavaScript Array with a few additional methods
  • A field is created via field() and wraps a single value with .get() & .set() accessor methods

There are two additional derived data types:

  • A calculation is created via calc() and is a special function which takes no arguments
  • A collection view is created via *View() collection/view methods and behaves like a read-only JavaScript Array with a few additional methods

Reads and writes to all of these data types are monitored by Gooey. When any changes are observed, Gooey schedules a recalculation of any derived data types that depend on those changes.


model()

Create a model object, which is initialized from the provided target.

tsx
function model<T extends {}>(target: T, debugName?: string | undefined): Model<T>

Avoid mutating target after creating a model. The optional debugName is only used for diagnostic purposes.

Keys added / removed to the model are not tracked, only the fields present in the value passed to model() are tracked. If you want a dynamic map of key → value, use dict().


dict()

Create a dict object, which is initially empty or populated from the provided entries.

tsx
interface Dict<TKey, TValue> {
    clear(): void;
    delete(key: TKey): void;
    forEach(fn: (value: TValue, key: TKey) => void): void;
    get(key: TKey): TValue;
    has(key: TKey): boolean;
    set(key: TKey, value: TValue): this;

    entries(debugName?: string): View<[TKey, TValue]>;
    keys(debugName?: string): View<TKey>;
    values(debugName?: string): View<TValue>;
    subscribe(handler: (events: DictEvent<TKey, TValue>[]) => void)): void;
}

function dict<TKey, TValue>(entries?: [TKey, TValue][], debugName?: string | undefined): Dict<TKey, TValue>

The optional debugName is only used for diagnostic purposes.


collection()

Create a collection initially populated with items.

tsx
export interface Collection<T> {
    // Array-like methods:
    splice(start: number, deleteCount?: number | undefined): T[];
    splice(start: number, deleteCount: number, ...items: T[]): T[];
    push(...items: T[]): number;
    pop(): T | undefined;
    shift(): T | undefined;
    unshift(...items: T[]): number;
    sort(cmp?: ((a: T, b: T) => number) | undefined): this;
    reverse(): this;

    // Collection-specific methods 
    reject: (pred: (val: T) => boolean) => T[];
    moveSlice: (fromIndex: number, count: number, toIndex: number) => void;
    mapView: <V>(
        fn: (val: T) => V,
        debugName?: string | undefined
    ) => View<V, ArrayEvent<T>>;
    filterView: (
        fn: (val: T) => boolean,
        debugName?: string | undefined
    ) => View<T, ArrayEvent<T>>;
    flatMapView: <V>(
        fn: (val: T) => V[],
        debugName?: string | undefined
    ) => View<V, ArrayEvent<T>>;

    subscribe: (handler: (event: ArrayEvent<T>[]) => void) => () => void;
}

function collection<T>(items: T[], debugName?: string | undefined): Collection<T>

Avoid mutating items after creating a collection. The optional debugName is only used for diagnostic purposes.

Note: .mapView(), .filterView(), and .flatMapView() return View types, which are like read-only collections.

Note: Additional methods that live on Array.prototype that may not be listed in the above are present on Collection objects, and will behave correctly.


collection views

Views are read-only collections that contain transformed/filtered values. They’re produced via the .mapView(), .filterView(), and .flatMapView() methods, which live both on Collection and View objects.

tsx
export interface View<T> {
    // Disabled Array-like methods:
    splice(start: number, deleteCount?: number | undefined): never;
    splice(start: number, deleteCount: number, ...items: T[]): never;
    push(...items: T[]): never;
    pop(): never;
    shift(): never;
    unshift(...items: T[]): never;
    sort(cmp?: ((a: T, b: T) => number) | undefined): never;
    reverse(): never;

    // View-specific methods 
    mapView: <V>(
        fn: (val: T) => V,
        debugName?: string | undefined
    ) => View<V, ArrayEvent<T>>;
    filterView: (
        fn: (val: T) => boolean,
        debugName?: string | undefined
    ) => View<T, ArrayEvent<T>>;
    flatMapView: <V>(
        fn: (val: T) => V[],
        debugName?: string | undefined
    ) => View<V, ArrayEvent<T>>;

    subscribe: (handler: (event: ArrayEvent<T>[]) => void) => () => void;
}

When a view’s source collection has changed, a task to update the view is scheduled. This occurs asynchronously, so if you mutate a Collection and immediately attempt to read a View, the anticipated updates will not occur.

Note: If you need to ensure a view is accurately up-to-date prior to accessing, you may call flush() to ensure the global dependency graph is processed and all updates have been performed.


field()

Create a field that has an initial value. The optional debugName is only used for diagnostic purposes.

tsx
type FieldSubscriber<T> = (error: undefined, val: T) => void;

interface Field<T> {
    get: () => T;
    set: (val: T) => void;
    subscribe: (observer: FieldSubscriber<T>) => () => void;
    retain: () => void;
    release: () => void;
}

function field<T>(value: T, debugName?: string): Field<T>

Fields are single values that Gooey tracks reads from & writes to. It’s often more convenient to lump multiple field-like values into an object with model(), but sometimes just a single piece of state will do.

The optional debugName is only used for diagnostic purposes.


calc()

Create a calculation. Calculations are functions that take no arguments and return a value.

tsx
type CalcUnsubscribe = () => void;

interface Calculation<T> {
    (): T;
    onError: (handler: (error: Error) => T) => this;
    setCmp: (eq: (a: T, b: T) => boolean) => this;
    subscribe: (handler: (value: T) => void) => CalcUnsubscribe;
    subscribeWithError: (handler: (...args: [error: undefined, value: T] | [error: Error, value: undefined]) => void) => CalcUnsubscribe;
}

function calc<T>(fn: () => T, debugName?: string | undefined): Calculation<T>

Calculations are automatically memoized per their dependencies, so multiple invocations will return the result of the first invocation, until the calculation is recalculated. When any of the calculation’s dependencies have changed, a task to recalculate the calculation is scheduled. This occurs asynchronously, so if you mutate a dependency and immediately attempt to get the result of a calculation, you will receive a cached result.

Calculation dependencies are determined on a per-invocation basis, so if a calculation does not read a potentially dependent value (by returning early or short-circuiting), it will not be recalculated if that unaccessed dependency changes.

Note: If you need to ensure a calculation is accurately up-to-date prior to calling it, you may call flush() to ensure the global dependency graph is processed synchronously and all updates have been performed.


Model Helpers

In addition to functioning just like JavaScript objects, there are a few helper utilities that can be used with models.


model.field()

Obtain a Field object representing one of the values in a model.

tsx
model.field<T extends {}, K extends keyof T>(sourceModel: Model<T>, field: K): Field<T[K]>

Behavior is not defined if the provided key is not present in the model.


model.subscribe()

Subscribe to a stream of events when modifications are made to the target model.

tsx
export enum ModelEventType {
    SET = 'set',
}

export type ModelEvent =
    { type: ModelEventType.SET; prop: string; value: any }

model.subscribe<T extends {}>(targetModel: Model<T>, handler: (event: ModelEvent[]) => void, debugName?: string | undefined): () => void

When targetModel is changed, a task to notify subscribers is scheduled. This task to call handler is performed asynchronously.

The returned function can be called to unsubscribe.

The kinds of modifications are:

  • ModelEventType.SET - an existing field is updated on the model

Map Methods

Maps behave like built-in JavaScript Map objects, with keys/values/entries methods augmented to return View objects instead of iterators.

To avoid conflicting with the built-in Map<TKey, TValue> type, the map type is exported as Dict<TKey, TValue>.


.get()

Read a value associated with a key.

tsx
interface Dict<TKey, TValue> {
    get(key: TKey): TValue | undefined;
}

If a the provided key is not present in the map, undefined is returned, and the read is still considered to be tracked as a dependency from the map.


.has()

Check if a value is associated with a key.

tsx
interface Dict<TKey, TValue> {
    has(key: TKey): boolean;
}

If a the provided key is not present in the map, the read is still considered to be tracked as a dependency from the map.


.set()

Associate a value with a key.

tsx
interface Dict<TKey, TValue> {
    set(key: TKey, value: TValue): this;
}

For convenience, the map instance is returned so this operation may be chained.


.delete()

Remove a key.

tsx
interface Dict<TKey, TValue> {
    delete(key: TKey): void;
}

If the key is not present in the map, nothing occurs.


.clear()

Empty a map.

tsx
interface Dict<TKey, TValue> {
    clear(): void;
}

All keys and values are removed from the map.


.forEach()

Iterate over all values and keys.

tsx
interface Dict<TKey, TValue> {
    forEach(iter: (value: TValue, key: TKey) => void): void;
}

Note: this is considered to be a read on the set of keys that live in the map. Iterating over a map’s keys in a calculation will cause the calculation to be recalculated if any key is changed, added, or deleted.


.entries()

Get a view holding the map’s entries.

tsx
interface Dict<TKey, TValue> {
    entries(): View<[TKey, TValue]>;
}

Unlike JavaScript’s Map’s .entries method, this method produce a View that reflects the entries in the tracked map as the tracked map is updated over time.

Sort order is not defined.


.keys()

Get a view holding the map’s keys.

tsx
interface Dict<TKey, TValue> {
    keys(): View<TKey>;
}

Unlike JavaScript’s Map’s .keys method, this method produce a View that reflects the keys in the tracked map as the tracked map is updated over time.

Sort order is not defined.


.values()

Get a view holding the map’s keys.

tsx
interface Dict<TKey, TValue> {
    values(): View<TValue>;
}

Unlike JavaScript’s Map’s .values method, this method produce a View that reflects the values in the tracked map as the tracked map is updated over time.

Sort order is not defined.


.subscribe()

Subscribe to changes as the map is processed.

tsx
enum DictEventType {
    ADD = 'add',
    SET = 'set',
    DEL = 'del',
}

type DictEvent<K, V> =
    | { type: DictEventType.ADD; prop: K; value: V }
    | { type: DictEventType.SET; prop: K; value: V }
    | { type: DictEventType.DEL; prop: K; value?: V };

interface Dict<TKey, TValue> {
    subscribe(handler: (events: DictEvent<TKey, TValue>[]) => void): () => void
}

When the map is changed, a task to notify subscribers is scheduled. This task to call handler is performed asynchronously.

The returned function can be called to unsubscribe.

The kinds of modifications are:

  • DictEventType.ADD - a new key is added to the map
  • DictEventType.SET - an existing key is updated on the map
  • DictEventType.DEL - an existing key is removed from the map

.field()

Get a Field representing a key in the map.

tsx
interface Dict<TKey, TValue> {
    field(key: TKey): Field<TValue | undefined>;
}

Note: behavior is not specified if the key is removed from the map. Do not use this method if the key may not be present in your map.


Collection & View Methods

Collections and views behave just like plain old (read-only, for views) JavaScript Arrays.

For convenience, there are a few additional methods added to these types, which help for common operations, to create derived views, and to observe changes to these objects over time.


Standard array methods

Under the hood, both Collections and Views use a Proxy to wrap around an underlying Array. This means they behave exactly like an Array, and all of the built-in methods are supported (and will be supported as the language evolves).

The following read-only methods are supported both on Collections and Views:

And the following methods that mutate the underlying array are only supported on Collections:


.mapView()

Create a derived collection view holding transformed items.

tsx
interface Collection<T> {
    mapView: <V>(
        mapFn: (val: T) => V,
        debugName?: string | undefined
    ) => View<V, ArrayEvent<T>>;
}

When items are added to, removed from, resorted, and reassigned within the source collection, a task is scheduled to update the derived View with mapped versions of those items by calling the provided mapFn. This task is performed asynchronously.

The optional debugName is only used for diagnostic purposes.

Note: If you need to ensure a view is accurately up-to-date prior to accessing, you may call flush() to ensure the global dependency graph is processed and all updates have been performed.

Note: Unlike .map(), .mapView()’s callback function does not take an index or a reference to the original array as parameters.

Note: The order in which mapFn is called is not defined and cannot be relied upon.


.filterView()

Create a derived collection view holding filtered items.

tsx
interface Collection<T> {
    filterView: (
        filterFn: (val: T) => boolean,
        debugName?: string | undefined
    ) => View<T, ArrayEvent<T>>;
}

When items are added to, removed from, resorted, and reassigned within the source collection, a task is scheduled to update the derived View depending on if they pass the provided filter filterFn. This task is performed asynchronously.

The optional debugName is only used for diagnostic purposes.

Note: If you need to ensure a view is accurately up-to-date prior to accessing, you may call flush() to ensure the global dependency graph is processed and all updates have been performed.

Note: Unlike .filter(), .filterView()’s callback function does not take an index or a reference to the original array as parameters.

Note: The order in which filterFn is called is not defined and cannot be relied upon.


.flatMapView()

Create a derived collection view holding transformed, concatenated items.

tsx
interface Collection<T> {
    flatMapView: <V>(
        flatMapFn: (val: T) => V[],
        debugName?: string | undefined
    ) => View<V, ArrayEvent<T>>;
}

When items are added to, removed from, resorted, and reassigned within the source collection, a task is scheduled to update the derived View with a concatenated set of items produced by applying the mapping flatMapFn. This task is performed asynchronously.

The optional debugName is only used for diagnostic purposes.

Note: If you need to ensure a view is accurately up-to-date prior to accessing, you may call flush() to ensure the global dependency graph is processed and all updates have been performed.

Note:** Unlike .flatMap(), .flatMapView()’s callback function **does not take an index or a reference to the original array as parameters.

Note: The order in which flatMapFn is called is not defined and cannot be relied upon.

Fun fact: .filterView() and .mapView() both use .flatMapView(), which is a generalized transform + filter method.


.reject()

Mutate the collection to remove items from a collection that fail a callbackFn function check. Like an in-place .filter() for removing items.

tsx
interface Collection<T> {
    reject: (callbackFn: (val: T) => boolean) => T[];
}

Note: the order in which callbackFn is applied is not defined.


.moveSlice()

Move a slice of a collection to another location within the collection.

tsx
interface Collection<T> {
    moveSlice: (fromIndex: number, count: number, toIndex: number) => void;
}

This function relocates a sequences of count item from a starting fromIndex to a destination toIndex. toIndex is the destination offset after removing count items from fromIndex.

Consider this function to be equivalent to: collection.splice(toIndex, 0, ...collection.splice(fromIndex, count))

Note: the difference between two splice operations is that moveSlice does not cause mapped/filtered views to be re-mapped/re-filtered. This means that if a collection’s mapView() is a JSX node, moveSlice will cause the underlying DOM nodes to be relocated to the target location in the destination array, as opposed to being removed and rendered anew.


.subscribe()

Subscribe to changes to a target collection.

tsx
enum ArrayEventType {
    SPLICE = 'splice',
    MOVE = 'move',
    SORT = 'sort',
}

type ArrayEvent<T> =
    | {
          type: ArrayEventType.SPLICE;
          index: number;
          count: number;
          items?: T[] | undefined;
      }
    | {
          type: ArrayEventType.MOVE;
          from: number;
          count: number;
          to: number;
      }
    | {
          type: ArrayEventType.SORT;
          from: number;
          indexes: number[];
      };


interface Collection<T> {
    subscribe: (handler: (event: ArrayEvent<T>[]) => void) => () => void;
}

When modifications are made to the target collection a task is scheduled to notify subscribers. The task to call handler is performed asynchronously. The returned function can be called to unsubscribe.

The kinds of events are:

  • ArrayEventType.SPLICE - a splice operation has been performed: count values are removed and all the items are added at a target index
  • ArrayEventType.MOVE - a run of count items is moved from an start from index to a destination to index. to is the destination index after removing count items from the from index.
  • ArrayEventType.SORT - a subset of the collection was reordered with a new set of indexes. The indexes array is always is zero-indexed, even if from is greater than zero.

Note: Notifications for individual item assignments .push(), etc… are all encoded as ArrayEventType.SPLICE operations.

For convenience, a function applyArrayEvent is exported, which allows you to apply an array event to a target array:

tsx
function applyArrayEvent<T>(target: T[], event: ArrayEvent<T>): void;

Field Methods

Fields are single values that Gooey tracks reads from & writes to. It’s often more convenient to lump multiple field-like values into an object with model(), but sometimes just a single piece of state will do.


.get()

Retrieve the value from a field.

tsx
interface Field<T> {
    get: () => T;
}

.set()

Set the value in a field.

tsx
interface Field<T> {
    set: (val: T) => void;
}

.subscribe()

Subscribe to changes to the value associated with the field.

tsx
type FieldSubscriber<T> = (error: undefined, val: T) => void;

interface Field<T> {
    subscribe: (observer: FieldSubscriber<T>) => () => void;
}

The returned function unsubscribes from the field.

When the field is modified, a task is scheduled to notify subscribers. This task to call observer is called asynchronously. If a field is modified multiple times before notification, the observer will only be called once with the last value at the next notification. The first error parameter is always undefined.

Note: The callback will not be called with any field values written before subscription starts, even if the writes and subscriptions are called within the same batch for a flush().


.retain() and .release()

Retain and release a field

tsx
interface Field<T> {
    retain: () => void;
    release: () => void;
}

There is no reason to call these functions directly.

Fields have an internal reference count. When this reference count is greater than 0, the field is “active”, present in the global dependency graph, and subscriptions are processed. When subscribing to a field, or when a field is bound to the DOM, it is automatically retained.


Calculation Methods

Calculations are functions which are automatically memoized/cached and get recalculated when their dependencies change. There are a few methods on calculation objects to configure the equality check, subscribe to changes, and handle errors.


.onErr()

Set the error handler for a calculation.

tsx
class CycleError extends Error {
    ...
}

interface Calculation<T> {
    onError: (handler: (error: Error) => T) => this;
}

When an active calculation encounters an error, the error handler is invoked and its return value is used is used as the result of the calculation. Calculations that depend on this calculation and subscriptions to this calculation will be completely unaware that the error has occurred.

There are two kinds of errors that can occur: * A cycle error, when the calculation is identified as being part of a cycle (it has a self-dependency, or depends on a value that depends on itself. In this case the error is an instance of the CycleError class. * An uncaught exception thrown when calling the calculation’s function.

A calculation with an error will be recalculated (and its error handler called if present and an error occurs in recalculation) if any of its dependencies have changed.


.setCmp()

Set the comparison function for a calculation.

tsx
interface Calculation<T> {
    setCmp: (eq: (a: T, b: T) => boolean) => this;
}

If a calculation is recalculated and its comparison function returns true, things that depend on the calculation will not be notified that a dependency has changed.

By default, this comparison function is strict equality: (a, b) => a === b.


.subscribe()

Subscribe to calculation recalculation events.

tsx
type CalcUnsubscribe = () => void;

interface Calculation<T> {
    subscribe: (handler: (value: T) => void) => CalcUnsubscribe;
}

The handler will be called with the new result each time the calculation is recalculated. The returned value is a function which can be called to unsubscribe from the subscription.

If the recalculation is an error, the handler will not be called.


.subscribeWithError()

Subscribe to calculation recalculation events and errors.

tsx
type CalcUnsubscribe = () => void;

class CycleError extends Error {
    ...
}

interface Calculation<T> {
    subscribeWithError: (handler: (...args: [error: undefined, value: T] | [error: Error, value: undefined]) => void) => CalcUnsubscribe;
}

The handler will be called with the new result each time the calculation is recalculated or has an error. The returned value is a function which can be called to unsubscribe from the subscription.

If the result of the recalculation is not an error, the error parameter will be undefined and value will be the result of the calculation.

If the result of the recalculation is an uncaught error, the error will be the Error instance, and val will be undefined. If the calculation became part of a cycle, the error will be an instance of CycleError.


.retain() and .release()

Retain and release a calculation

tsx
interface Calculation<T> {
    retain: () => void;
    release: () => void;
}

In most cases, you should not need to call this function.

Calculations have an internal reference count. When this reference count is greater than 0, the calculation is “active”: it will be automatically memoized & automatically recalculated when its dependencies change. When the reference count is equal to 0, the calculation is “inert”: it will behave just like a normal function.

Gooey internally retains calculations when they are a dependency / bound to JSX, so this function is only needed if you want to keep memoization and recalculation when the calculation is not actively used by the system.

Do not rely on the recalculation behavior of retained calculations to trigger “side effects”, instead use .subscribe().


JSX

JSX is used by Gooey to create “render nodes” which it uses when rendering to the DOM.

In order for things to work right, your JS compiler toolchain should be configured to

  • Compile JSX expressions into Gooey.createElement
  • Compile JSX Fragment expressions into Gooey.Fragment

This can be accomplished with a .tsconfig file containing:

json
{
    "compilerOptions": {
        "jsx": "react",
        "jsxFactory": "Gooey",
        "jsxFragmentFactory": "Gooey.Fragment"
    }
}

There are two types of tags in JSX:

  • Component JSX tags start with capital letters, like <MyComponent />, which render function / class components
  • Intrinsic Element JSX tags start with a lowercase letter, like <div />, which represent built-in or custom DOM elements

mount()

Render and attach JSX to the DOM.

tsx
function mount(target: Element, jsx: RenderNode): () => void

Until mounted or retained, a JSX expression evaluates to an inert data structure called RenderNode. Mounting a JSX expression causes the expression to be rendered to HTML and attached to a target DOM Element. The returned function unmounts the JSX from the DOM.

Note: It is recommended, but not necessary for the target DOM element to be empty. If the target element contains children, Gooey will append the rendered JSX to the end of the existing child elements. Behavior is undefined if the set of target‘s child nodes are changed outside of Gooey’s knowledge while JSX is mounted.

Note: Mounting is a very cheap operation. Do not hesitate to mount to multiple areas of the DOM (i.e. it’s perfectly safe to mount to document.head to add additional nodes in the document head, or mount to the title element to provide a dynamic title.). Internally, every intrinsic element rendered performs a mount to render its contained JSX.


Element Props

Intrinsic Element JSX props correspond to the HTML name of the attribute. JSX props should look a whole lot like HTML properties.

React-like camelCase / attribute names are not supported. Event handlers are a bit different, which we’ll see later:

Will Not WorkPlease Use Instead
<div className="my-block" /><div class="my-block" />
<label htmlFor="my-input" /><label for="my-input" />
<input type="text" enterKeyHint="search" /><input type="text" enterkeyhint="search" />
<button onClick={handleClick} /><button on:click={handleClick} />

There are a few special Intrinsic Element JSX props:

The ref prop:

The ref Intrinsic Element JSX prop can either be a function or an object created with the ref() function.

If provided a function, it is passed a reference to the element when it is mounted or null when it is unmounted.

If provided a ref object, its .current property is set to a reference to the element when it is mounted or null when unmounted.

For example, to obtain a reference to a div element, you can use a callback function as the ref prop:

tsx
<div ref={(divEl: HTMLDivElement | undefined) => {
    if (divEl) {
        console.log('div mounted');
    } else {
        console.log('div unmounted');
    }
} />

Event Handlers

Props prefixed with on: add standard DOM event handlers. oncapture: and onpassive: can be used to add useCapture and passive DOM event handlers, respectively.

For example:

JSXEquivalent JavaScript
<input on:input={onChange} />inputEl.addEventListener('input', onChange)
<input oncapture:keydown={onKeyDown} />inputEl.addEventListener('keydown', onKeyDown, { capture: true })
<div onpassive:scroll={onScroll} />divEl.addEventListener('scroll', onScroll, { passive: true })

This syntax allows you to listen for custom events as naturally as you would for native events.


Component JSX Props

Component JSX props are passed as an object to component render functions / class constructors.

If a component is passed children via JSX, they are accessible via the children prop. If a single child is passed, children is the value of the child. If multiple children are passed, children is an array of values.

Aside from children, there are no special Component JSX props, all other props are passed as-is.

Calculation / Field props

If a calculation or field is passed as the value of a prop, the calculation’s result/field’s value is bound to the prop and will be automatically updated as the calculation is recalculated/field is updated.

Style & CSS props

Props prefixed with style: bind to values on the style object. Props prefixed with cssprop: bind to custom properties with an added -- prefix.

JSXEquivalent JavaScript
<div style:color="red">Hello</div>divEl.style.setProperty('color', 'red')
<div cssprop:my-prop="3px">Hello</div>divEl.style.setProperty('\-\-my-prop', '3px')

This syntax allows you to conveniently bind calculations and fields to custom or built-in CSS properties.

The attr: and prop: props:

When interacting with 3rd party web components, it’s sometimes necessary to be very specific about whether or not you are setting an element’s attributes to a value, or setting a property.

Props prefixed with attr: are set as element attributes, and those prefixed with prop: are set as element member properties.

JSXEquivalent JavaScript
<input attr:value="3" />inputEl.setAttribute('value', '3')
<input prop:value="3" />inputEl.value = '3';

Compiled JSX: createElement()

When JSX is compiled to JavaScript, it becomes calls to Gooey.createElement.

tsx
function createElement<TProps>(
    type: string | Component<TProps>,
    props: TProps,
    ...children: JSX.Node[]
): IntrinsicRenderNode | ComponentRenderNode<TProps>

You shouldn’t ever need to call createElement manually, it should be taken care of as part of writing JSX.


<Fragment>

<Fragment>...</Fragment> or <>...</> is used to render any number of JSX children side-by-side.

tsx
const Fragment: Component<{ children?: JSX.Node | JSX.Node[] }>

A Fragment is equivalent to rendering an array of children in place, it’s just a bit more convenient.


JSX Child Types

There are a few types exported to the global JSX namespace both for tooling and for convenience:

  • JSX.Element is the type produced by evaluating a JSX expression.
  • JSX.Node is the type that is renderable as a JSX child. This is a non-standard type, but often helpful to reference.
tsx
type JSXNode =
    | string
    | number
    | boolean
    | null
    | undefined
    | bigint
    | symbol
    | Function
    | Element
    | RenderNode
    | Calculation<JSXNode>
    | Collection<JSXNode>
    | View<JSXNode>
    | Field<JSXNode>
    | Array<JSXNode>;

namespace JSX {
    /**
     * The core type produced by a JSX expression
     */
    type Element = RenderNode;

    /**
     * The core type allowable as a child node in a JSX expression
     */
    type Node = JSXNode;
}

The effect of rendering each JSX.Node type is as follows:

Value TypeDisplay
string / number / bigintRendered as a Text node with the value's string representation
boolean / null / undefinedNot rendered at all
function / symbolNot rendered at all, and a warning is logged.
ElementElement objects are rendered as-is, so <div>{document.createElement('div')}</div> works as you would expect.
Calculation<JSXNode>Calculations that produce JSX.Node types are bound in location to the DOM. When the calculation is recalculated, the prior result is removed and the next result is inserted in place.
Collection<JSXNode> / View<JSXNode>Collections and views that contain JSX.Node types are bound in location to the DOM, with their items placed sequentially. As the collection/view changes has items added/removed/relocated, the corresponding DOM nodes are added/removed/relocated.
Array<JSXNode>Arrays of JSX.Node types are rendered in location to the DOM, with their items placed sequentially.

All other values will not be rendered at all, but a warning will be logged.


ref()

Ref objects hold mutable references to values, which may be changed over time. Ref callbacks receive references to values over time.

tsx
interface Ref<T> {
    current: T | undefined;
}

function ref<T>(val?: T): Ref<T> {
    return new Ref(val);
}

type RefCallback<T> = (val: T | undefined) => void;

type RefObjectOrCallback<T> = Ref<T> | RefCallback<T>;

An intrinsic element’s ref prop can be set to either a Ref object or a RefCallback function.

When the element is mounted, the ref is assigned to/called with the underlying DOM Element. When the element is unmounted, the ref is assigned to/called with undefined.

Note: Because JSX may be detached and reattached, it is possible for the same DOM Element to be mounted and unmounted multiple times.


.retain() & .release()

Increment/decrement the refcount for JSX.

When it is greater than zero, the JSX is rendered to DOM nodes and dynamic behavior is “active”.

When it reaches zero, the JSX and its underlying resources will be freed.

tsx
interface RenderNode {
    retain: () => void;
    release: () => void;
}

Under the hood, Gooey uses refcounting to determine if a data types and JSX nodes is actively used. A piece of JSX will retain its children while alive, and release its children when destroyed.

When a piece of JSX is retained, it is considered to be “active”. This means that the underlying DOM nodes will be created and any calculations/collections/views that contribute to that DOM tree will be processed. The JSX does not need to be attached to the DOM for it to be retained.

This allows for a few different capabilities:

  • Large portions of JSX anticipated to be rendered can be rendered “detached” so that it can be quickly attached to the DOM without needing to build the entire tree.
  • Elements like <canvas> that hold rendering contexts may be temporarily removed and reattached without destroying the underlying rendering context and drawn content.
  • Components that create and hold state may be temporarily detached and reattached without losing that state.

IntrinsicObserver()

A built-in component to observe child DOM elements as they are added and removed from the DOM over time.

tsx
export enum IntrinsicObserverEventType {
    MOUNT = 'mount',
    UNMOUNT = 'unmount',
}

export type IntrinsicObserverNodeCallback = (
    node: Node,
    event: IntrinsicObserverEventType
) => void;

export type IntrinsicObserverElementCallback = (
    element: Element,
    event: IntrinsicObserverEventType
) => void;

const IntrinsicObserver: Component<{
    nodeCallback?: IntrinsicObserverNodeCallback | undefined;
    elementCallback?: IntrinsicObserverElementCallback | undefined;
    children?: JSX.Node | JSX.Node[];
}>

This component allows for observing child DOM elements without knowing any information about the structure or behavior of its children passed.

There are two event values passed to each callback:

  • IntrinsicObserverEventType.MOUNT - called immediately after a child Element/Node has been mounted to the DOM
  • IntrinsicObserverEventType.UNMOUNT - called immediately before a child Element/Node has been unmounted from the DOM

The two different callbacks allow for different levels of specificity:

  • nodeCallback is called with all Node subtypes (Text, Element, CData, etc…)
  • elementCallback is called with only Element subtypes

Note: If IntrinsicObserver is used on a detached/attached RenderNode, it may be mounted / unmounted with children. In this case, the nodeCallback / elementCallback callbacks will be called at the corresponding mount / unmount lifecycle.

This can be used to accomplish a number of common tasks where a component needs to add event listeners to its children, but doesn’t care about how those children are constructed.

For example, here’s a component that adds a click event handler to all of its children, regardless of how and when those children are rendered (even if they’re dynamically added & removed):

tsx
import Gooey, { Component, IntrinsicObserver } from '@srhazi/gooey';

interface Props {
    onClick: (event: MouseEvent, el: Element) => void;
    children?: JSX.Node[] | JSX.Node;
}
const ClickHandler: Component<Props> = ({ onClick, children }) => (
    <IntrinsicObserver
        elementCallback={(element, event) => {
            if (event === 'mount' && element instanceof HTMLElement) {
                element.addEventListener('click', (event) =>
                    onClick(event, element)
                );
            }
            if (event === 'unmount' && element instanceof HTMLElement) {
                element.removeEventListener('click', (event) =>
                    onClick(event, element)
                );
            }
        }}
    >
        {children}
    </IntrinsicObserver>
);

Components & DOM

Components allow for abstracting JSX into composable pieces, which each can hold their own state / manage their interface.

tsx
interface ClassComponentConstructor<TProps> {
    new (props: TProps): ClassComponent<TProps>;
}

type Component<TProps = {}> =
    | FunctionComponent<TProps>
    | ClassComponentConstructor<TProps>;

There are two types of components, which both have equivalent capabilities:

  • Function components are functions which take props & lifecycle handlers and return JSX
  • Class components are classes that take props in their constructor, optionally have lifecycle handler methods, and have a .render() method which returns JSX

Each have the exact same capabilities and are provided separately only for stylistic reasons:

  • Sometimes a component is simple enough that everything can be done in one go; a function component is best
  • Other times a component grows enough complexity so that it’s more clear to have separate initialization, rendering, and internal helper methods; a class component is best.

Function components

Function components are functions that start with a capital letter, take props & lifecycle handlers as parameters, and return JSX.

tsx
const UnusedSymbolForChildrenOmission: unique symbol;
type EmptyProps = { [UnusedSymbolForChildrenOmission]?: boolean };

interface ComponentLifecycle {
    onMount: (callback: () => void) => (() => void) | void;
    onUnmount: (callback: () => void) => void;
    onDestroy: (callback: () => void) => void;
    onError: (handler: (e: Error) => JSX.Element | null) => void;
}

type FunctionComponent<TProps = {}> = (
    props: TProps & EmptyProps,
    lifecycle: ComponentLifecycle
) => JSX.Element | null;

A function component is a function that takes props & lifecycle handlers and returns JSX (or null).

The lifecycle handlers passed as a second parameter to the component allow the component to hook into various events that occur during the lifecycle of the component:

  • onMount is called immediately after the component is attached to the DOM; the optional return function is called immediately before the component is detached from the DOM
  • onUnmount is called immediately before the component is detached from the DOM
  • onDestroy is called when the component is no longer attached to the DOM and nothing retains the component
  • onError is called when the component or any of its children encounter an unhandled error; the returned JSX replaces the component

While the typical lifecycle of a component is for it to be rendered, mounted, and then later on mounted and destroyed; it’s possible for a component to be mounted and unmounted multiple times and even detached and moved to another location in the DOM. The overall lifecycle of a component is:

RenderedAttachedMountedCreateDestroyAdoptOrphanMountUnmountEmit clean up DOM operationsRendered DOM nodes added to parentCall function component render functionCall onDestroy() lifecycle handlerCall onMount() lifecycle handlerCall onUnmount() lifecycle handler

Note: The EmptyProps type is to disallow children from being passed to a component that takes no props. See component children for more information.

Note: The render function is called exactly once, there is no such thing as “re-rendering” a component. If you want a component to render dynamic contents, wrap the desired areas in calc() functions that return JSX.


Class components

Class components extend ClassComponent and have a constructor that takes props as the only parameter:

tsx
const UnusedSymbolForChildrenOmission: unique symbol;
type EmptyProps = { [UnusedSymbolForChildrenOmission]?: boolean };

class ClassComponent<TProps = EmptyProps> {
    props: TProps;
    constructor(props: TProps) {
        this.props = props;
    }

    render?(): JSX.Element | null;
    onMount?(): (() => void) | void;
    onUnmount?(): void;
    onDestroy?(): void;
    onError?(e: Error): JSX.Element | null;
}

The optional lifecycle methods allow the component to hook into various events that occur during the lifecycle of the component:

  • onMount is called immediately after the component is attached to the DOM; the optional return function is called immediately before the component is detached from the DOM
  • onUnmount is called immediately before the component is detached from the DOM
  • onDestroy is called when the component is no longer attached to the DOM and nothing retains the component
  • onError is called when the component or any of its children encounter an unhandled error; the returned JSX replaces the component

While the typical lifecycle of a component is for it to be constructed & rendered, mounted, and then later on mounted and destroyed; it’s possible for a component to be mounted and unmounted multiple times and even detached and moved to another location in the DOM. The overall lifecycle of a component is:

Canvas 1Layer 1RenderedAttachedMountedCreateDestroyAdoptOrphanMountUnmountEmit clean up DOM operationsRendered DOM nodes added to parentCall constructor() and .render()Call .onDestroy() lifecycle methodCall .onMount() lifecycle methodCall .onUnmount() lifecycle method

Note: The EmptyProps type is to disallow children from being passed to a component that takes no props. See component children for more information.

Note: The .render() method is called exactly once, there is no such thing as “re-rendering” a component. If you want a component to render dynamic contents, wrap the desired areas in calc() functions that return JSX.


Component children

Sometimes it’s desirable for a component to accept children, so it can place that children somewhere in its area of concern.

Children aren’t necessarily limited to JSX nodes. It’s possible for a child to be an object, a function, or any value at all.

Components may specify in their type signature the number and kind of children that they accept.

Due to historical reasons, the types are a bit awkward, so for reference here are the recommended variations:

tsx
import Gooey, { Component } from '@srhazi/gooey';

// If the `children` prop is not specified, it is a type error to pass children to this component
const AcceptsNoChildren: Component<{ name: string }> = ({ name }) => (/* ... */);

// It will be a type error to pass more than one child to this component
const AcceptsOptionallyOneChild: Component<{ children?: JSX.Node }> = ({ children }) => (/* ... */);

// It will be a type error to pass zero or 2+ children to this component
const AcceptsExactlyOneChild: Component<{ children: JSX.Node }> = ({ children }) => (/* ... */);

// It will be a type error to pass zero or 2+ children to this component
const AcceptsMoreThanOneChild: Component<{ children: JSX.Node | JSX.Node[] }> = ({ children }) => (/* ... */);

Note: the quantity of children change the type of the children prop:

  • If a single child value is passed to a component, children will be its children prop.
  • If multiple children are passed to a component, an array of those children will be its children prop.

Note: It is not valid for an intrinsic element to be attached to two different places at the same time. Attempts to place children in multiple locations in the JSX tree will cause an exception to be thrown.


Web Components

In addition to JSX expressions that can be mounted to elements, Gooey allows you to define custom DOM elements using the Web Components APIs.


defineCustomElement()

Custom elements are user-defined custom HTML tags which contain a hyphen, like <my-label>...</my-label> or <cool-button>...</cool-button>.

When defining a custom element, users may specify the list of attributes to be observed for changes, and provide a Component function which allows for customization of contents and behavior.

Custom elements are initialized when they are mounted to the document, and uninitialized when they are unmounted. Element instances have a .retain() and .release() pair of methods, which allow for preventing the uninitialization while unmounted.

Custom elements can be used to add dynamic functionality to plain HTML documents without needing to mount() components to the page.

tsx
interface WebComponentOptions<
    TKeys extends string,
    TShadowMode extends 'open' | 'closed' | undefined,
    TExtends extends string | undefined
> {
    tagName: `${string}-${string}`;
    Component: WebComponent<TKeys, TShadowMode>;
    observedAttributes?: TKeys[] | undefined;
    formAssociated?: boolean | undefined;
    shadowMode?: TExtends extends ShadowSupportedTags ? TShadowMode : undefined;
    delegatesFocus?: boolean | undefined;
    extends?: TExtends;
    hydrateTemplateChild?: boolean | undefined;
}

function defineCustomElement<
    TKeys extends string,
    TShadowMode extends 'open' | 'closed' | undefined = undefined,
    TExtends extends string | undefined = undefined
> (options: WebComponentOptions<TKeys, TShadowMode, TExtends>): void;

The options object provided to defineCustomElement has fields:

  • tagName (required): the name of the tag, which must be a
  • Component (required): the component function; See Custom Component Functions below
  • observedAttributes: an array of string attributes that your component function is interested in
  • shadowMode: pass 'open' or 'closed' to have your component render to be a “Shadow Component” and render to the Shadow DOM; otherwise it is a “traditional component”; more on this below
  • formAssociated: pass true here if your component should participate in Form Submission
  • delegatesFocus: pass true here component Focus Delegation
  • extends: pass the tag name this component is extending; Note: this DOES NOT work in Safari
  • hydrateTemplateChild: pass false here if your component is expected to be present in the DOM at the time of its registration where it has a single <template> element child; Note: this almost certainly will never be the case.

Web Component Functions

Custom Component Functions behave like standard Component functions (see the Function component documentation), but their lifecycle handlers have additional functionality.

tsx
export interface WebComponentLifecycle extends ComponentLifecycle {
    host: HTMLElement;
    shadowRoot: ShadowRoot | undefined;
    elementInternals: ElementInternals | undefined;
    addEventListener(
        type: string,
        listener: (this: HTMLElement, ev: Event, el: HTMLElement) => any,
        options?: boolean | AddEventListenerOptions,
    ): void;
    bindElementInternalsAttribute: (
        param: WebComponentInternalsKey,
        value: Dyn<string | null>,
    ) => () => void;
    bindFormValue: (formValue: Dyn<FormValue>) => () => void;
    bindValidity: (validity: Dyn<Validity>) => () => void;
    checkValidity: () => void;
    reportValidity: () => void;
}

type WebComponentProps<
    TKeys extends string,
    TShadowMode extends 'open' | 'closed' | undefined
> = TShadowMode extends undefined
    ? { [Key in TKeys]?: Dyn<string | undefined>; } & { children: JSXNode; }
    : { [Key in TKeys]?: Dyn<string | undefined>; };

type WebComponent<
    TKeys extends string,
    TShadowMode extends 'open' | 'closed' | undefined
> = (
    props: WebComponentProps<TKeys, TShadowMode>,
    lifecycle: WebComponentLifecycle,
) => JSX.Element | null;

A web component function takes props & lifecycle handlers and returns JSX (or null).

The props passed to a web component are dynamic string or undefined values. Use dynGet() and dynSubscribe() to access prop values.

The children prop is only provided to web components that do not render to the Shadow DOM. If you are rendering to the Shadow DOM, you must use <slot> elements and slot attributes to place children.

The lifecycle handlers passed as a second parameter to the component allow the component to hook into various events that occur during the lifecycle of the component.

The standard component lifecycle handlers are present:

  • onMount is called immediately after the component is attached to the DOM; the optional return function is called immediately before the component is detached from the DOM
  • onUnmount is called immediately before the component is detached from the DOM
  • onDestroy is called when the component is no longer attached to the DOM and nothing retains the component
  • onError is called when the component or any of its children encounter an unhandled error; the returned JSX replaces the component

Additionally, web components have the following values in their lifecycle parameter:

  • host: a reference to the host Element
  • shadowRoot: a reference to the element’s shadow root (if shadowMode is 'open' or 'closed')
  • elementInternals: a reference to the element’s ElementInternals instance (if extends is not provided)
  • addEventListener: a convenience method to add an event listener to the host element. The event listener is automatically removed when deinitialized.
  • bindElementInternalsAttribute: a convenience method to bind a dynamic value to an ElementInternals member.
  • bindFormValue: a convenience method to bind a dynamic value to the element’s underlying form value via setFormValue
  • bindValidity: a convenience method to bind a dynamic value to the element’s validity via setValidity
  • checkValidity: a convenience method to check the validity of the element via checkValidity
  • reportValidity: a convenience method to report the validity of the element via reportValidity

Note: The extends option is not supported (and unlikely to ever be supported) on Safari. See https://github.com/WebKit/standards-positions/issues/97 for more information.

Note: The render function is called exactly once, there is no such thing as “re-rendering” a component. If you want a component to render dynamic contents, wrap the desired areas in calc() functions that return JSX.


Dynamic Helpers

Gooey comes with a few helper functions which aid in building components.

These are all helpers that handle accessing both static data (like a plain old JavaScript string) and data that is wrapped as a field or calc.

These helpers are all prefixed with dyn:

  • dynGet()
  • dynSet()
  • dynSubscribe()

type Dyn<T>

When building components, it can be helpful to export a prop that can be passed either a calculation, field, or plain old value.

The Dyn<T> type allows for annotating theses sorts of flexible items.

tsx
type Dyn<T> = T | Field<T> | Calculation<T>;

See dynGet(), dynSet(), and dynSubscribe() for more information.

As an example, the following code shows how a reactive checkbox component could be created. Its “checked” prop will be bound to the checked state.

tsx
import Gooey, {
  Component,
  mount,
  field,
  calc,
  Dyn,
  dynGet,
  dynSet,
  dynSubscribe,
  ref,
} from '@srhazi/gooey';

// A checkbox component which takes a dynamic "checked" prop value and label
const Checkbox: Component<{
  checked?: Dyn<boolean>;
  label: JSX.Node;
}> = ({ checked = field(false), label }, { onMount }) => {
  const inputRef = ref<HTMLInputElement>();
  return (
    <label>
      <input
        ref={inputRef}
        type="checkbox"
        checked={checked}
        on:input={(e, el) => {
          if (!dynSet(checked, el.checked)) {
            // Failed to set for some reason, revert to the checkd value
            el.checked = dynGet(checked);
          }
        }}
      />{' '}
      {label}
    </label>
  );
};

const checkedField = field(false);
const checkedCalc = calc(() => !checkedField.get());

mount(
  document.body,
  <>
    <p>
      <Checkbox label="No checked prop specified, brings its own state" />
    </p>
    <p>
      <Checkbox checked label="Always true" />
    </p>
    <p>
      <Checkbox checked={checkedField} label="A field which can be toggled" />
    </p>
    <p>
      <Checkbox
        checked={checkedCalc}
        label="A calc which is the opposite of the field"
      />
    </p>
    <button on:click={() => checkedField.set(!checkedField.get())}>
      Toggle field
    </button>
  </>
);

dynGet()

Read the value from a Dyn.

tsx
function dynGet<T>(wrapper: Dyn<T>): T

If the provided dyn is a calculation, it is equivalent to dyn.get(). If dyn is a field, it is equivalent to dyn.get(). If dyn is any other value, it is equivalent to dyn.


dynSet()

Write a value to a Dyn, if supported.

tsx
function dynSet<T>(wrapper: Dyn<T>, value: T): boolean

If the provided dyn is a field, it is equivalent to dyn.set(value) and true is returned, indicating a successful write.

Otherwise, true is returned, indicating an unsuccessful write.


dynSubscribe()

Subscribe to a stream of values for a Dyn.

tsx
function dynSubscribe<T>(wrapper: Dyn<T>, callback: (val: T) => void): () => void

If the provided dyn is a field or calculation, it is equivalent to dyn.subscribe(handler). The returned function unsubscribes from the subscription.

Otherwise, dyn is a plain old value. In this case, handler is called with the plain old value, and a noop function is returned. No further calls to handler will be performed.


Processing Engine

Gooey keeps track of reads and writes to all of the authoritative and derived data types it supports. By also tracking reads within calc() functions, it can build a global dependency graph, which is used to ensure derived data is recalculated in the correct order.

When a piece of data has changed, the global dependency graph needs to be processed in order for derived data to be updated and subscriptions to be notified.

By default, Gooey updates derived data types (collection views and calculations) and calls subscriptions to data in batches, asynchronously. This is performed as a microtask that gets scheduled when any piece of authoritative data is modified.

To trigger a manual, synchronous processing of the global dependency graph, call flush().

To configure how processing is scheduled, use subscribe().

Note: Stale reads of derived data are possible if they are read synchronously after the data they depends on has changed.


reset()

Reset all internal state

tsx
function reset(): void

This function should only be called in the context of a test. It resets all of the internal state of Gooey to an initial state.

Note: The behavior for accessing data types that have been created prior to calling reset() is not defined and should not be relied upon


subscribe()

Provide a custom scheduler to perform a flush of the system.

tsx
function subscribe(scheduler?: ((performFlush: () => void) => () => void) | undefined): void

The scheduler function is called when Gooey determines that the global dependency graph needs to be processed. It is provided a performFlush callback that processes the global dependency graph when called. The scheduler function should return a function that prevents it from calling performFlush in the future.

By passing undefined as the scheduler, automatic processing is disabled and the global dependency graph is not automatically processed.

The default scheduler schedules processing at the end of the event loop via queueMicrotask (if possible, if not via setTimeout(performFlush, 0).


flush()

Manually trigger processing of the global dependency graph.

tsx
function flush(): void

An example of how this is useful is in an event handler to synchronously update UI prior to leaving the event handler (so that you may transition focus to an element that is revealed via state change).

Note: if called while processing the global dependency graph (i.e. within a calculation body while recalculating), the function call does nothing and is a no-op.


setLogLevel() & getLogLevel()

Set & retrieve log level

tsx
type LogLevel = 'error' | 'warn' | 'info' | 'debug';

function getLogLevel(): LogLevel;

function setLogLevel(logLevel: LogLevel);

By default the log level is 'warn'.


debug() & debugSubscribe()

Get diagnostic information about the global dependency graph

tsx
function debug(activeVertex?: Processable | undefined, label?: string | undefined): string;

function debugSubscribe(fn: (label: string, graphviz: string) => void): () => void

The debug() function will return a graphviz dot formatted representation of the global dependency graph.

The debugSubscribe() function will call the callback fn with a stream of graphviz dot formatted representations of the global dependency graph as it changes and is processed. This function generates tremendous amounts of data, do not use it.