Guide

Here’s a brief introduction to Gooey. We’ll go over installation, configuration, and a small login form application.


Core concepts

Gooey has a few specific core concepts which have specific meaning:

  • calculation: a special function that takes no arguments and returns a value. When actively used by Gooey or manually retained, calculations are automatically memoized and recalculated when necessary.
  • field: a single value that can act as a dependency for calculations.
  • model: an object that behaves like a plain old JavaScript object containing a fixed set of keys, except all property access reads are treated as dependencies when accessed within the body of a calculation.
  • dict: an object that behaves like a plain old JavaScript Map, except all item accesses are treated as dependencies when accessed within the body of a calculation.
  • collection: an object that behaves like a plain old JavaScript array, except all item access reads are treated as dependencies when accessed within the body of a calculation.
  • view: an object that behaves like a read-only JavaScript array, except all item access reads are treated as dependencies when accessed within the body of a calculation.
  • render node: the result of evaluating a JSX expression.
  • props: the property names and values passed to a JSX element.
  • component: a function that takes props and a set of lifecycle handlers as arguments and returns a JSX expression.

Keep these in mind as we go through the rest of this guide.


Installation & Configuration

Gooey works best with TypeScript installed, and we’ll be using vite here for a really quick & easy development experience. So go ahead and run:

sh
~ $ mkdir gooey-demo
~ $ cd gooey-demo
~/gooey-demo $ npm init --yes
~/gooey-demo $ npm install --save @srhazi/gooey
~/gooey-demo $ npm install --save-dev typescript vite
~/gooey-demo $ mkdir src

And let’s create a minimal tsconfig.json file with the right settings for Gooey:

json
{
  "compilerOptions": {
    "lib": ["ES2019", "DOM"],
    "target": "ES2019",

    "jsx": "react",
    "jsxFactory": "Gooey",
    "jsxFragmentFactory": "Gooey.Fragment",

    "module": "ES2015",
    "moduleResolution": "node",

    "isolatedModules": true,
    "noEmit": true,
    "sourceMap": true,

    "strict": true
  },
  "include": [
    "./src/**/*.ts",
    "./src/**/*.tsx"
  ]
}

Hello, world!

Ok, with that installation out of the way, let’s create the first app.

First, let’s make a simple “Hello, world” app to ensure things are all working correctly.

Create a src/app.tsx file containing the following code:

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

mount(document.body, <h1>Hello, world!</h1>);

Simple enough, eh?

Now we need to load this app on an html page. Let’s create an index.html file containing the following, which will be the main entry point for this application.

html
<!DOCTYPE html>
<html lang="en">
  <head>
	<meta charset="utf-8">
	<title>Gooey Demo</title>
  </head>
  <body>
	<script type="module" src="./src/app.tsx"></script>
  </body>
</html>

Ok! That’s everything we need to get this working!

All we need to do now is start the vite server, and open up the application in a browser:

sh
$ node_modules/.bin/vite serve --open

If everything was set up correctly, your browser should open with a “Hello, world” message!

If you go ahead and change the text to “Hello, Gooey!”, the page should automatically reload and you should see the new message.


JSX

Like React and other frameworks, Gooey uses JSX to render HTML.

JSX elements that start with lowercase letters are intrinsic elements. JSX elements that start with uppercase letters are component functions, more on that later.

Unlike other frameworks, intrinsic elements in Gooey have prop names that match those used when writing HTML: DOM attributes are named after their standard DOM names. There is no camel-casing or special-casing involved. All of the following are valid JSX elements:

tsx
const heading = <h1 class="title">Hello, world</h1>;
const label = <label for="my-input">Name:</label>;
const image = <img srcset="cool1x.png, cool2x.png 2x" />;

Refs

If you need to get a reference to the underlying DOM Element instance, you can pass a special ref prop to intrinsic elements. This can either be a ref object, created via the ref() export or a ref callback, which is a function that takes either the element reference or undefined as its first parameter.


Making a form

Ok, so let’s put together a login form. If you change your src/app.tsx file to read this:

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

mount(
  document.body,
  <fieldset>
    <legend>Example Login</legend>
    <p>
      <label for="login-username-1">Username: </label>
      <input type="text" id="login-username-1" minlength="3" />
    </p>
    <p>
      <label for="login-password-1">Password: </label>
      <input type="password" id="login-password-1" />
    </p>
    <button type="submit">Submit</button>
  </fieldset>
);

You should see a minimal, but usable login form.

It works, but there isn’t any interactivity. Let’s add some!


Event handling

To add event listeners to intrinsic elements, a few different attribute prefixes can be used, depending on the kind of event listener:

on:eventname (i.e. on:click, on:focus, etc…): binds a standard event handler to the DOM node. Equivalent to element.addEventListener('eventname', handler);

oncapture:eventname (i.e. oncapture:click, oncapture:focusin, etc…): binds a standard event handler to the DOM node that passes true as the optional useCapture parameter. Equivalent to element.addEventListener('eventname', handler, true)

onpassive:eventname (i.e. onpassive:scroll, etc…): binds a passive event handler to the DOM node. Passive event listeners cannot call preventDefault() or stopPropagation() on the event. Equivalent to element.addEventListener('eventname', handler, { passive: true }).

Note: these prefixes allow us to handle both native DOM events and custom DOM events.

Note: for convenience, a reference to the element the event was attached to is passed as the second parameter to event handlers, and will be correctly inferred the type of the intrinsic element.

Adding to our example, here’s how we could respond to clicking on that button:

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

function onLogInClick() {
  alert('We can respond to events!');
}

mount(
  document.body,
  <fieldset>
    <legend>Example Login</legend>
    <p>
      <label for="login-username-2">Username: </label>
      <input type="text" id="login-username-2" minlength="3" />
    </p>
    <p>
      <label for="login-password-2">Password: </label>
      <input type="password" id="login-password-2" />
    </p>
    <button type="submit" on:click={onLogInClick}>
      Submit
    </button>
  </fieldset>
);

Attribute binding

Intrinsic elements may have their attributes bound to the result of a calculation. The recalculation is automatic, and occurs after any of the calculation’s dependencies have changed.

Note: this processing of recalculations is not synchronous when a dependency changes, it occurs in the next event cycle.

To demonstrate, here’s how we would change our example to disable the “Submit” button while the fields are invalid:

tsx
import Gooey, { mount, calc, model } from '@srhazi/gooey';

const state = model({
  username: '',
  password: '',
});

function onLogInClick() {
  alert(
    `Attempt login with username=${state.username} and password=${state.password}`
  );
}

mount(
  document.body,
  <fieldset>
    <legend>Example Login</legend>
    <p>
      <label for="login-username-3">Username: </label>
      <input
        on:input={(event, inputEl) => {
          state.username = inputEl.value;
        }}
        type="text"
        id="login-username-3"
        minlength="3"
      />
    </p>
    <p>
      <label for="login-password-3">Password: </label>
      <input
        on:input={(event, inputEl) => {
          state.password = inputEl.value;
        }}
        type="password"
        id="login-password-3"
      />
    </p>
    <button
      type="submit"
      disabled={calc(
        () => state.username.length < 3 || state.password.length === 0
      )}
      on:click={onLogInClick}
    >
      Submit
    </button>
  </fieldset>
);

This code is starting to get a bit unwieldy, so let’s break it out into a few reusable pieces.


Components

Components are functions which take two parameters: Props and Lifecycle methods. More on the lifecycle methods later.

Component functions are called exactly once throughout the lifecycle of the component, so it is perfectly safe to define state or perform other operations that should only be performed once within the function body.

When placed as nodes in a JSX tree, components are called with props. Unlike intrinsic elements, there are no special props. Your components may choose to have props named ref or on:click (and may choose to forward these props to elements the component renders), but there is no special handling of them.

Components may optionally take a children prop, which allows it to be passed any value as the children of the component. If there is one child in the JSX tree, the value is passed as-is, if there are multiple children, they are passed as an array of values.

Our example is getting big and relies on global state. Let’s pull our authentication field into a component, tidy it up a bit, so it can be reusable and have isolated state:

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

let id = 0;

// We had two <label> & <input> pairs, so let's break each into a single component
type LabeledInputProps = {
  /** children - the displayed contents of the label */
  children: JSX.Node;

  /** onUpdate - called when the input value changes at all */
  onUpdate: (value: string) => void;

  /** minlength - an optional constraint on length */
  minlength?: string;

  /** type - the kind of input we're dealing with */
  type?: 'text' | 'password';
};
const LabeledInput: Component<LabeledInputProps> = ({
  children,
  onUpdate,
  minlength,
  type = 'text',
}) => {
  const uniqueId = `input_${id++}_4`;
  return (
    <p>
      <label for={uniqueId}>{children}: </label>
      <input
        id={uniqueId}
        type={type}
        minlength={minlength}
        on:input={(event, inputEl) => onUpdate(inputEl.value)}
      />
    </p>
  );
};

// And let's build a form component to hold the state for all the fields, and have an optional callback
type OnAuthenticationSubmit = (username: string, password: string) => void;
const AuthenticationForm: Component<{ onSubmit: OnAuthenticationSubmit }> = ({
  onSubmit,
}) => {
  const state = model({ username: '', password: '' });

  const onSubmitClick = () => {
    onSubmit(state.username, state.password);
  };

  const calcIsInvalid = calc(
    () => state.username.length < 3 || state.password.length === 0
  );

  return (
    <fieldset>
      <legend>Example Login</legend>
      <LabeledInput
        type="text"
        minlength="3"
        onUpdate={(val) => {
          state.username = val;
        }}
      >
        Username
      </LabeledInput>
      <LabeledInput
        type="password"
        onUpdate={(val) => {
          state.password = val;
        }}
      >
        Password
      </LabeledInput>
      <button type="submit" disabled={calcIsInvalid} on:click={onSubmitClick}>
        Submit
      </button>
    </fieldset>
  );
};

// To demonstrate that this all fits together, we can now have multiple
// instances of the AuthenticationForm component!
mount(
  document.body,
  <>
    <AuthenticationForm
      onSubmit={(username, password) =>
        alert(`Submit one: ${username} + ${password}`)
      }
    />
    <AuthenticationForm
      onSubmit={(username, password) =>
        alert(`Submit two: ${username} + ${password}`)
      }
    />
  </>
);

Class Components

Sometimes a component grows large enough to have many functions defined in its body alongside lifecycle methods, and it would be a bit clearer to collect these into a single class with methods.

That being said, this is entirely a matter of taste. Function components and class components have the exact same capabilities.

To demonstrate, here’s the above example ported to use class components.

tsx
import Gooey, {
  Model,
  ClassComponent,
  model,
  calc,
  mount,
} from '@srhazi/gooey';
let id = 0;

// We had two <label> & <input> pairs, so let's break each into a single component
interface LabeledInputProps {
  /** children - the displayed contents of the label */
  children: JSX.Node;

  /** onUpdate - called when the input value changes at all */
  onUpdate: (value: string) => void;

  /** minlength - an optional constraint on length */
  minlength?: string;

  /** type - the kind of input we're dealing with */
  type?: 'text' | 'password';
}

class LabeledInput extends ClassComponent<LabeledInputProps> {
  uniqueId: string;

  constructor(props: LabeledInputProps) {
    super(props);
    this.uniqueId = `input_${id++}_5`;
  }

  render() {
    return (
      <p>
        <label for={this.uniqueId}>{this.props.children}: </label>
        <input
          id={this.uniqueId}
          type={this.props.type ?? 'text'}
          minlength={this.props.minlength}
          on:input={(event, inputEl) => this.props.onUpdate(inputEl.value)}
        />
      </p>
    );
  }
}

// And let's build a form component to hold the state for all the fields, and have an optional callback
interface AuthenticationFormProps {
  onSubmit: (username: string, password: string) => void;
}
class AuthenticationForm extends ClassComponent<AuthenticationFormProps> {
  state: Model<{ username: string; password: string }>;

  constructor(props: AuthenticationFormProps) {
    super(props);
    this.state = model({ username: '', password: '' });
  }

  onSubmitClick = () => {
    this.props.onSubmit(this.state.username, this.state.password);
  };

  onUpdateUsername = (val: string) => {
    this.state.username = val;
  };

  onUpdatePassword = (val: string) => {
    this.state.password = val;
  };

  calcIsInvalid = calc(
    () => this.state.username.length < 3 || this.state.password.length === 0
  );

  render() {
    return (
      <fieldset>
        <legend>Example Login</legend>
        <LabeledInput
          type="text"
          minlength="3"
          onUpdate={this.onUpdateUsername}
        >
          Username
        </LabeledInput>
        <LabeledInput type="password" onUpdate={this.onUpdatePassword}>
          Password
        </LabeledInput>
        <button
          type="submit"
          disabled={this.calcIsInvalid}
          on:click={this.onSubmitClick}
        >
          Submit
        </button>
      </fieldset>
    );
  }
}

// To demonstrate that this all fits together, we can now have multiple
// instances of the AuthenticationForm component!
mount(
  document.body,
  <>
    <AuthenticationForm
      onSubmit={(username, password) =>
        alert(`Submit one: ${username} + ${password}`)
      }
    />
    <AuthenticationForm
      onSubmit={(username, password) =>
        alert(`Submit two: ${username} + ${password}`)
      }
    />
  </>
);

To be honest, the LabeledInput component is probably better off as function component, but splitting the class methods out in our AuthenticationForm is a bit more clear.

In any case, unlike React, class components and function components are completely equivalent, and you are free to switch between them as you choose.


Component Lifecycles

Component functions receive a second ComponentLifecycle parameter, which allows components to hook into the lifecycle events that occur:

If a component renders child components, those child lifecycle events are called before the parent lifecycle events are called.

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

These methods are called at different times:

  • onMount: Gets called immediately after the component is attached to the DOM. It may optionally return a function that gets called immediately before the component is detached from the DOM.
  • onUnmount: Gets called immediately before the component is attached to the DOM.
  • onDestroy: Gets called after all of the retainers release the component.
  • onError: Gets called if any unhandled exception is thrown while rendering / rerendering children. The returned JSX will be rendered as the components contents.

For class components, instead of being provided as a separate parameter, you may provide implementations of these methods on your class itself.


Collections & views

It’s very common to render lists of things that change over time. Collections and Collection Views allow to build ordered lists of data that can both act as dependencies in calculations and display these lists efficiently.

A collection is created via collection(initialValuesArray), and acts just like a plain old JavaScript array.

A collection view is created via calling coll.mapView((item) => transform(item)), and produces a read-only collection that is derived from the values in the collection. When the collection changes, the view is automatically updated on the next event cycle.

Collections and views (and static arrays) are renderable as JSX, and their items are added as if they were right next to each other in the DOM. When a mounted collection/view is updated, the DOM is automatically updated to reflect the new items. Unmodified items are not modified or re-rendered.

Note: unlike other frameworks, there is no need to provide a special “key” prop to each rendered item when rendering an array.

This is probably best demonstrated with an example, which extends the above authentication form example to render a list of login attempts:

tsx
import Gooey, {
  Component,
  model,
  calc,
  collection,
  mount,
} from '@srhazi/gooey';

let id = 0;

// We had two <label> & <input> pairs, so let's break each into a single component
type LabeledInputProps = {
  /** children - the displayed contents of the label */
  children: JSX.Node;

  /** onUpdate - called when the input value changes at all */
  onUpdate: (value: string) => void;

  /** minlength - an optional constraint on length */
  minlength?: string;

  /** type - the kind of input we're dealing with */
  type?: 'text' | 'password';
};
const LabeledInput: Component<LabeledInputProps> = ({
  children,
  onUpdate,
  minlength,
  type = 'text',
}) => {
  const uniqueId = `input_${id++}_6`;
  return (
    <p>
      <label for={uniqueId}>{children}: </label>
      <input
        id={uniqueId}
        type={type}
        minlength={minlength}
        on:input={(event, inputEl) => onUpdate(inputEl.value)}
      />
    </p>
  );
};

// And let's build a form component to hold the state for all the fields, and have an optional callback
type OnAuthenticationSubmit = (username: string, password: string) => void;
const AuthenticationForm: Component<{ onSubmit: OnAuthenticationSubmit }> = ({
  onSubmit,
}) => {
  const state = model({ username: '', password: '' });

  const onSubmitClick = () => {
    onSubmit(state.username, state.password);
  };

  const calcIsInvalid = calc(
    () => state.username.length < 3 || state.password.length === 0
  );

  return (
    <fieldset>
      <legend>Example Login</legend>
      <LabeledInput
        type="text"
        minlength="3"
        onUpdate={(val) => {
          state.username = val;
        }}
      >
        Username
      </LabeledInput>
      <LabeledInput
        type="password"
        onUpdate={(val) => {
          state.password = val;
        }}
      >
        Password
      </LabeledInput>
      <button type="submit" disabled={calcIsInvalid} on:click={onSubmitClick}>
        Submit
      </button>
    </fieldset>
  );
};

// Here, we hold a collection of login attempt items
interface LoginAttempt {
  username: string;
  password: string;
}
const loginAttempts = collection<LoginAttempt>([]);

mount(
  document.body,
  <>
    <AuthenticationForm
      onSubmit={(username, password) => {
        // This collection works just like a normal array:
        loginAttempts.push({
          username,
          password,
        });
      }}
    />
    <p>Login attempts:</p>
    <ul>
      {
        // But the mapView method allows the collection to be bound to JSX results, as items are added to the collection!
        // No need for any sort of `key` prop, it just works!
        loginAttempts.mapView(({ username, password }) => (
          <li>
            Login attempt: username=
            {JSON.stringify(username)}; password=
            {JSON.stringify(password)}
          </li>
        ))
      }
    </ul>
  </>
);

Advanced topics

There are a few things about Gooey that are not typically found in other frameworks.


Detached rendering

Detached rendering is the ability to mark JSX so that it is rendered while not attached to the DOM. This allows JSX to be rendered, removed from the DOM, moved to another location, and then readded to the DOM – all without destroying the underlying elements / component state.

This is unlike most other UI frameworks. In Gooey, JSX trees can be moved around in the DOM and can be disconnected and reconnected without destroying and recreating the underlying DOM elements.

JSX expressions evaluate to the RenderNode type, which has a well-defined interface. RenderNode values have .retain() and .release() methods, which are used to indicate that you are taking responsibility for the lifecycle of the JSX node and promise to call .release() when the JSX node is no longer used. Only when a JSX node is unmounted and all its retain calls are released does the RenderNode destroy its created elements / state.

This means it’s possible for a component to be rendered once; mounted, unmounted, and remounted in different locations within the DOM multiple times; and then ultimately unmounted and destroyed when it is no longer used.

This is where the onDestroy lifecycle method comes in. When a component is fully released, it can tap into that method to clean up any persistent state.

Keep in mind this capability also means the onMount and onUnmount lifecycle methods may be called any number of times! Typically this won’t matter, but don’t write code under the assumption that they’re called exactly once.

Here’s an example drawing app that lets you write messages on a <canvas /> element, except the canvas moves to a different area every few seconds:

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

// A silly component that draws random phrases to a canvas on click
const ClickDrawing: Component = () => {
  const phrases = ['wow', 'neat', 'cool', 'fun', 'such', 'omg'];

  return (
    <canvas
      width="300"
      height="150"
      style="cursor: crosshair"
      class="bx by"
      on:click={(e, canvasEl) => {
        const ctx = canvasEl.getContext('2d');
        if (!ctx) return;
        ctx.save();
        ctx.font = '25px sans-serif';
        ctx.lineWidth = 5;
        ctx.miterLimit = 2; // limit harsh points
        ctx.strokeStyle = `black`;
        const hue = Math.floor(Math.random() * 360);
        ctx.fillStyle = `hsl(${hue} 100% 80%)`;
        const phrase = phrases[Math.floor(Math.random() * phrases.length)];
        const { width } = ctx.measureText(phrase);
        ctx.strokeText(phrase, e.offsetX - width / 2, e.offsetY, 480);
        ctx.fillText(phrase, e.offsetX - width / 2, e.offsetY, 480);
        ctx.restore();
      }}
    />
  );
};

const App: Component<{}> = (_props, { onMount, onDestroy }) => {
  const state = model<{ loc: 'red' | 'green' | 'detached' }>({ loc: 'red' });

  onMount(() => {
    const handle = setInterval(() => {
      if (state.loc === 'red') state.loc = 'green';
      else if (state.loc === 'green') state.loc = 'detached';
      else if (state.loc === 'detached') state.loc = 'red';
    }, 5000);
    return () => clearInterval(handle);
  });

  const drawingJsx = <ClickDrawing />;
  // While retained, JSX is rendered & updated
  drawingJsx.retain();

  onDestroy(() => {
    // Clean up, so we don't leak
    drawingJsx.release();
  });

  return (
    <>
      <h3>Detached rendering</h3>
      <p>
        The drawing moves between the red square, green square, and detached
        every 5 seconds.
      </p>
      <p>Its currently {calc(() => state.loc)}:</p>
      <div class="row">
        <div style="background-color: #fdd; width: 302px; height: 152px">
          {calc(() => (state.loc === 'red' ? drawingJsx : 'Inactive'))}
        </div>
        <div style="background-color: #dfd; width: 302px; height: 152px">
          {calc(() => (state.loc === 'green' ? drawingJsx : 'Inactive'))}
        </div>
      </div>
    </>
  );
};

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

Intrinsic Observer

When JSX is rendering, it internally is emitting a stream of events, like “add this DOM node” and “remove this DOM node” which its parent uses to place the rendered DOM nodes in the correct location.

This stream of events can be listened to by arbitrary components, allowing them to augment he emitted DOM nodes without knowing much of anything about how these nodes are constructed.

This allows components to add event listeners / observe the size of / do anything with the DOM nodes rendered by children passed to the component!

There’s a special exported component called IntrinsicObserver which gives you the capability to respond to these nodes.

To demonstrate, here’s an example component that tracks whether or not its children have focus.

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

interface FocusObserver {
  children?: JSX.Element[] | JSX.Element;
}
const FocusListener: Component<FocusObserver> = (
  { children },
  { onMount, onUnmount }
) => {
  const state = model({ hasFocus: false });
  let childEls: Element[] = [];

  function updateHasFocus() {
    state.hasFocus = childEls.some((el) => el.contains(document.activeElement));
  }

  return (
    <fieldset>
      <legend>Has focus: {calc(() => (state.hasFocus ? 'yes' : 'no'))}</legend>
      <IntrinsicObserver
        elementCallback={(element, event) => {
          if (event === 'mount' && element instanceof HTMLElement) {
            childEls.push(element);
            element.addEventListener('focusin', updateHasFocus);
            element.addEventListener('focusout', updateHasFocus);
          }
          if (event === 'unmount' && element instanceof HTMLElement) {
            childEls = childEls.filter((el) => el !== element);
            element.removeEventListener('focusin', updateHasFocus);
            element.removeEventListener('focusout', updateHasFocus);
            updateHasFocus();
          }
        }}
      >
        {children}
      </IntrinsicObserver>
    </fieldset>
  );
};

const App: Component<{}> = (_props, { onMount }) => {
  const state = model({ isShowing: true });

  onMount(() => {
    const handle = setInterval(() => {
      state.isShowing = !state.isShowing;
    }, 1000);
    return () => clearInterval(handle);
  });

  return (
    <>
      <h4>IntrinsicObserver demo</h4>
      <FocusListener>
        <p>
          Here is some text with a <input type="text" value="text input" />
        </p>
      </FocusListener>
      <p>
        Some text outside a focus listener with a{' '}
        <input type="text" value="text input" />
      </p>
      <FocusListener>
        <>
          <p>
            More things <input type="text" value="to focus on" />
          </p>
          {calc(() =>
            state.isShowing ? (
              <p>
                Dynamic item <a href="#">with a link</a>
              </p>
            ) : (
              <p>But the link disappears</p>
            )
          )}
          <p>
            More items{' '}
            <label>
              <input type="checkbox" /> to interact with
            </label>
          </p>
        </>
      </FocusListener>
    </>
  );
};

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

That's it!

That covered just about all of the main moving parts of Gooey. All you need to do is construct your application state with model, dict, field, and collection objects, in whatever shape makes the most sense for your application.


Final note

If you’re familiar with React, you may have a gut reaction of “that can’t be good for performance” when we did things like pass inline-defined functions as attributes or avoided using any sort of “key” prop when rendering collections/arrays.

However, none of these reactions are accurate: Gooey does not use a virtual DOM, component functions are rendered exactly once, and the overall API is designed so that the simple thing is also fast.