How to build complex forms without tons of callbacks

General technique for binding input fields to object field references

·

5 min read

How to build complex forms without tons of callbacks

There's an interesting idea in imgui libraries based on C where the input field takes a pointer to the value it's supposed to edit:

    input_field("label", &object.field)

We can take inspiration from this idea to define input components in (p)react without using callbacks.

We want to write this:

    <Input ref={ref(object, 'field')} />

instead of this:

    <input value={object.field}
        onChange={e => object.field = e.target.value}
    />

Here's why:

  • Zero callbacks

  • Code volume smaller

  • No duplicating object.field

Contemporary react based apps overuse closures. We should reduce the amount of closures as much as we can. Assume by default that closures come with performance penalties and find other default ways of achieving tasks.

For implementing this idea, we still need a callback, but it will be a single regular function, not a dynamic closure at every usage site.

We need a way to pass the ref to the input element without closures. The easy way to do this is to encode the reference as a string into the DOM attributes of the input element.

Object ID

We can easily come up with numeric ids for object references, but we need them to be stable; they should not change between render cycles.

Javascript does not give us the "address" of object references, but internally they are addresses, and the builtin Map object uses those addresses.

Here's a pair of functions to get an id for an object and also get an object back from the id.

let idmap = new Map<unknown, number>()
let objmap = new Map<number, unknown>()

export function objectId(obj: unknown): number {
    let v = idmap.get(obj)
    if (v === undefined) {
        v = nextObjectId()
        idmap.set(obj, v)
        objmap.set(v, obj)
    }
    return v
}

export function objectById<T = any>(id: number): T | null {
    return objmap.get(id) as T ?? null;
}

Leaking memory

Whenever we call objectId(...) on something, we hold a strong reference to it in the idmap and objmap. This means we should never call it on "temporary" local objects.

It also means we need a function to clear the idmap and objmap that we can call at the appropriate time. If the application we're developing is an SPA (single page application) with client side routing, we can call this function when the route changes.

export function clear() {
    idmap.clear()
    objmap.clear()
}

One possible alternative is to use WeakMap instead of map, but then it can't work with strings, which we want to be able to work with.

Field Ref

For the field ref, we can define a type with two keys: obj and key.

This is the closest thing to an arbitrary C pointer in Javascript (in terms of what we can do with it): we can use it to read and write arbitrary data without having to know anything about where that data is coming from.

export function ref<T, K extends keyof T>(obj: T, key: K): Ref<T[K]> {
    return { obj, key } as Ref<T[K]>
}

export function get<T>(r: Ref<T>): T {
    return r.obj[r.key]
}

export function set<T>(r: Ref<T>, value: T) {
    r.obj[r.key] = value
}

The definition of Ref<T> is constructed such that T is the type stored in obj.key.

Making this type in Typescript requires an ugly hack:

declare const _ref_type: unique symbol;
export type Ref<T = any> = RawRef & {
    [_ref_type]: T
}

export type RawRef<T = any> = {
    obj: T;
    key: keyof T;
}

When we call ref(object, "field"), the Typescript checker will:

  • Ensure that object.field is valid

  • Deduce the type of the returned ref

Example code:

type Person = {
    name: string;
    age: number;
}

let p: Person = { name: "Hello", age: 20 }

let x = ref(p, "name")
let y = ref(p, "age")

The Typescript checker automatically deduces that x has type Ref<string> and that y has type Ref<number>.

If you type a bad field name like this, it will report an error:

let z = ref(p, "inv")

Ref String Encoding

When we call ref(....) the object we get back will be a temporary local object. It will not have a stable id, so we can't just use objectId(...) on it; we'd get something different every time, and we'd leak memory, as discussed above.

Instead, we can get the id of the object and the field name. String literals have stable references, so we can call objectId on them.

function encodeRefAttr(ref: refs.Ref): string {
    let objectId = refs.objectId(ref.obj);
    let fieldId = refs.objectId(ref.key);
    return `${objectId}:${fieldId}`;
}

function decodeRefAttr(sref: string): refs.Ref {
    let [objectIdStr, fieldIdStr] = sref.split(":");
    return refs.ref(
        refs.objectById(parseInt(objectIdStr)),
        refs.objectById(parseInt(fieldIdStr)),
    );
}

Note the absence of any "validation" code in the decoder. We just assume the references are valid. If someone puts garbage in, they just get garbage out, similar to trying to pass garbage to a C function that expects a pointer.

Now, here's another helper to read the attribute of a specific DOM element.

function getAttr(el: HTMLElement | EventTarget | null, attr: string): string {
    if (el instanceof HTMLElement) {
        return el.getAttribute(attr) ?? "";
    } else {
        return "";
    }
}

It does not seem like much but it removes edge cases we don't care about, and we can also pass event.target to it without Typescript complaining.

The input handler

Now we have all the building blocks to create our Input component and define the input handler callback:


const INPUT_REF_ATTR = "input-ref";

function onInput(event: Event) {
    let target = event.target as HTMLInputElement;
    let ref = decodeRefAttr(getAttr(target, INPUT_REF_ATTR));
    let refValue = refs.get(ref);
    let value: any = target.value;
    refs.set(ref, value);
    core.scheduleRedraw();
}

export function inputAttrs(ref?: refs.Ref): any {
    if (!ref) {
        return {};
    }
    return {
        [INPUT_REF_ATTR]: encodeRefAttr(ref),
        value: refs.get(ref),
        onInput: onInput,
    };
}

export function Input(props: { ref: refs.Ref<string> }) {
    return <input {...inputAttrs(ref)} />
}

This is a "bare-bones" implementation, but I think it illustrates the idea.