Live vs React

Overview

Live is a reimplementation of the React <Component> tree and its hook system. It allows you to use popular reactive patterns to write code beyond UI widgets.

It's built to serve as the reactive core of Use.GPU, but there is nothing GPU- or graphics-specific about it.

Unlike React, Live does not produce an output DOM. Components can only render other components, or yield values back to parents.

Live is designed to play nice with real React, but shares no code with it.

Non-React extensions:

  • No-Hooks - Use if/else with hooks safely. Return early.
  • Continuations - Parents can run more code after children have rendered.
  • Context captures - Context providers in reverse. Parent gathers values sparsely from a sub-tree.
  • Yeet-Reduce - Gather values with incremental map-reduce along an entire sub-tree.
  • Quote and Reconcile - Reconcile components to a new subtree incrementally (aka react-react).
  • Morph - Parents can change Component type without unmounting all children.
  • Detach - Parent can decide when to dispatch render to children.

Modules

Components

Live follows the classic React model for <Component>.

  • Components: functions of immutable props and mutable state: no side-effects. Returns a declarative tree of child components to render. Parent components pass props and state to their children explicitly, only one-way.

  • Hooks: Components use hooks like useState to hold state. Effect hooks bind events to callbacks that modify that state. Hooks are memoized by declaring dependencies.

  • Context: pass implicit props down to an entire sub-tree at once, to avoid tedious "prop drilling".

  • Callbacks: if a child needs to call a parent, it has to have a specific purpose-made callback prop.

All the basics work the same and the mental model for hooks translates 95%.

Usage Cheat Sheet

These examples assume you are familiar with React 17-style functional components.

// This allows Live <JSX> to be used, by pretending to be React.
// You can't mix React and Live <JSX> in the same file.
import React from '@use-gpu/live';

// Main render entry point
import { render } from '@use-gpu/live';
import { App } from './app';

// e.g. on DOM load
render(<App />);

Here, App is a LiveComponent (LC). This is just like a React FunctionComponent (FC), with the same hooks API:

import React, { LC, useState } from '@use-gpu/live';

import { OtherComponent } from './other-component';

export const App: LC = () => {
  const [state, setState] = useState<number>(0);
  // ...
  return <OtherComponent state={state} />
};
import React from '@use-gpu/live';

type Props = {
  state: number,
};

export const OtherComponent: LC<Props> = (props: Props) => {
  // ...
};

There is also a children prop, as well as key for items in arrays.

Hooks

TL;DR

  • useCallback, useContext, useMemo, useRef, useState work like in React

  • useOne is shorthand for a useMemo with only 0-1 dependency (not an array)

  • useEffect and useLayoutEffect don't exist, use useResource

  • useResource is a sync useEffect + useMemo

  const t = useResource((dispose) => {
    // Creation
    const thing = new Thing();
    // Deferred cleanup function
    dispose(() => thing.dispose());
    // Returned value
    return thing;
  }, [...dependencies]);

No-Hooks

Every built-in useHook has a no-op useNoHook. You can safely write e.g.:

if (condition) {
  useMemo(...)
}
else {
  useNoMemo();
}

No-hooks will discard prior state, unsubscribe from contexts, cleanup resources, and so on.

Additionally, you are allowed to return early from a component, before calling all hooks. This will implicitly call a no-hook for them.

Context providers

Make a context

import { makeContext } from '@use-gpu/live';

type ContextValue = { foo: number };
const defaultValue = { foo: 1 };
const displayName = 'MyContext';

const MyContext = makeContext<ContextValue>(defaultValue, displayName);

Optional vs required

If defaultValue is undefined, the context is required. Its value has type T, and will throw an exception if used while missing.

If defaultValue is null, the context is optional. Its value has type T | null and can be used without being provided.

Provide a context

import React, { Provide } from '@use-gpu/live';

<Provide context={MyContext} value={value}>...</Provide>

Consume a context

import { useContext } from '@use-gpu/live';

const value = useContext(MyContext);

Context captures

This is the reverse of a context provider: it captures values from specific children across an entire sub-tree.

Make a capture

import { makeCapture } from '@use-gpu/live';

type ContextValue = { foo: number };
const displayName = 'MyContext';

const MyContext = makeCapture<ContextValue>(displayName);

Pass value to a capture

import { useCapture } from '@use-gpu/live';

// In a child
useCapture(MyContext, value);
Capturing from a context

Retrieve captured values

<Capture> takes a capture context and two props:

  • the tree of children to be rendered
  • a then continuation (values: T[]) => JSX.Element that receives captured values in tree order
import React, { Capture } from '@use-gpu/live';

<Capture context={MyContext} then={(values: T[]) => {
  // ...
}>...</Capture>

A continuation (aka a then prop) is similar to a classic render prop, except that it is run after children are done. Another difference is that it acts as a fully fledged component: you can e.g. use hooks, and it appears as a separate Resume(Component) node in the component tree.

Gather / Yeet

Similar to a capture, a component can render a <Gather> to gather values and continue after its children are rendered.

The main difference with Capture is that Gather is not context- or hook-based, and does not skip across the tree. The components of the tree are used directly as the nodes of a Map-Reduce, and there is only one gathering context active at any time.

<Gather> does not require a separate component to be mounted, and it does not appear in the component tree except in special cases. Here, both @<LinearRGB> and <RenderToTexture> are gathering:

Resume continuations

Any child can yeet a value, or an array of values. Yeets are indicated with triangles in the image.

return <Yeet>{value}</Yeet>

The parent renders a <Gather>, which takes two props:

  • the tree of children to be rendered
  • a then continuation (value: T[]) => JSX.Element that receives gathered values in tree-order
import React, { Gather } from '@use-gpu/live';

export const Component: LC = () => {
  const then = (values: any[]) => {
    // ...
  };

  return (
    <Gather then={then}>
      <Component />
      <Component />
    </Gather>
  );
};

Whenever the gathered values change, the continuation is re-run.

To gather multiple sets of values at the same time, use <MultiGather>. It will collect a Record<string, any[]>, grouped by key.

For general reductions, use <MapReduce> with a custom map and reduce function.

Render vs Yield

A common pattern is to make a component's render prop optional. If absent, the component will then <Yeet> the render argument(s) instead. This allows the render prop to handle the simple cases, while also allowing the component to be part of a <Gather> for more complex use.

Reconcile and Quote

Similar to how React reconciles HTML into a new DOM tree, Live can reconcile other Live components into a new subtree. This is done by quoting inside a <Reconcile>. This works the same way as quoting and unquoting in Lisp.

When you render:

<Reconcile>
  <First>
    <Quote>
      <Second>
        <Second>
          <Unquote>
            <First />
          </Unquote>
        </Second>
        <Second>
          <Unquote>
            <First />
          </Unquote>
        </Second>
      </Second>
    </Quote>
  </First>

This will produce the tree:

<Reconcile>
  <First>
    <First />
    <First />
<Resume(Reconcile)>
  <Second>
    <Second />
    <Second />

Reconcilers can be nested and quoted, to produce a series of derived trees, all updating incrementally.

Morph

When a component in the tree changes type, Live will unmount it and its children, like React. Usually this is what you want.

But for e.g. site pages in a router, this is undesirable, because two pages will often render the same layout inside. They might want to retain state for shared controllers too, but only if they match.

Morph in a router

This is a Morph. Just wrap the parent component that is changing type in <Morph> (or morph).

This will keep its matching children mounted, i.e. only if also rendered by the new parent. The parent itself always loses all state.

Detach in a tree

Detach

A component can render a detached child, to control when it renders. This is used e.g. by <Loop> to run its rendering loop.

Detaching will call a callback with a render function. The component calls it whenever it wants. It can be stored in a Ref:

import type { Task } from '@use-gpu/live';
import { detach, useRef } from '@use-gpu/live';

// Render a detached `<Run />` and store its render function in a ref.
const ref = useRef<Task>(null);
return detach(<Run />, (render: Task) => ref.current = render);

Detaching only defers the render: detached children are still part of the same logical tree and inherit the same context/captures/gathers. When a dependency reaches across, both sides will still update in concert.

To insulate a detached tree from particular changes, its contents needs to be wrapped in specific context providers. e.g. <Loop>'s <Run> provides a new FrameContext, so that per-frame children will only update when it dispatches the next paint cycle.

Native syntax

Live has a native non-JSX syntax, which is designed to be ergonomic in use, unlike React.createElement.

This is useful e.g. when mixing React code with Live code in the same file, while reserving JSX for React.

import { use, wrap, provide, yeet, gather } from '@use-gpu/live';

// <Component foo={bar} />
use(Component, {foo: bar})

// <Component><Child /></Component>
use(Component, {children: use(Child)})
// or
wrap(Component, use(Child))

// <Component><Child /><Child /></Component>
use(Component, {
  children: [
    use(Child),
    use(Child),
  ],
})

// <Provide context={MyContext} value={value}>...</Provide>
provide(MyContext, value, children)

// <Yeet>{value}</Yeet>
yeet(value)

// <Gather then={(value) => { }}>
//   <Child />
// </Gather>
gather(use(Child), (value) => { })

In native syntax, live components are not limited to a single props argument, but can have any number, including 0. The wider type is LiveFunction instead of LiveComponent.

menu
format_list_numbered