Skip to content

Higher order components

Karsten Schmidt edited this page Apr 19, 2018 · 3 revisions

@thi.ng/hdom supports different calling conventions for component functions. Which one to use largely depends on the nature of the component and is explained below.

Embedded component functions

Here is a simple link component:

// the first arg to a component function always is an
// injected arbitrary "context" object, which isn't used here...
const link = (_, href, body) => ["a", {href}, body];

Since this component is stateless, it can be used in the following equivalent ways:

// as so called "embedded" (or lazy) function
// the component fn & its args are wrapped in an array
// the function is only called later as part of
// the component tree processing
// note: the context arg is not specified here
// it will be injected automatically
["div", [link, "http://thi.ng", "thi.ng website"]]

// or direct/eager evaluation (at array contruction time)
// in this case we must provide a context arg ourselves (`null` here)
["div", link(null, "http://thi.ng", "thi.ng website")]

Higher-order components

Many real world components require some form of internal setup procedure, either to prepare child components, event listeners, pre-compute attributes and / or initialize local / private state etc. Another hallmark of such components is that they often return a component function (a closure) themselves. (The need for returning a function arises from the dynamic state the component refers to.)

Here's one such example:

// higher order component w/ local state
const counter = (i = 0) => {
    const attribs = { onclick: () => (i++) };
    return () => ["button", attribs, `clicks: ${i}`];
};

Since we don't want to (or sometimes simply can't or shouldn't) execute this setup procedure over and over again in each new frame, these components force the same kind of pattern on all their parent components:

const app = () => {
    // initialization
    const c1 = counter();
    const c2 = counter(100);
    // return actual component as closure
    return () => ["div", c1, c2];
}

// use the closure returned from `app()` as root component
start(document.body, app());

To avoid this as much as possible, it's best to keep as much state as possible outside components, e.g. in a central, single-source-of-truth state container as provided by @thi.ng/atom types.

That way we can pass values from the atom as args to the component using the "embedded function" calling convention shown earlier:

import * as atom from "@thi.ng/atom";

// counter values stored in central app state
const state = new atom.Atom({c1: 0, c2: 100});

const statelessCounter = (_, i) =>
    ["button", `clicks: ${i}`];

const app = ["div",
    [statelessCounter, state.deref().c1],
    [statelessCounter, state.deref().c2],
];

This is only half the solution. The remaining question now is how to update the counter values. Again, there're several options:

Using cursors

const state = ... // as above

// define cursors to specific values in the app state
const c1 = new atom.Cursor(state, "c1");
const c2 = new atom.Cursor(state, "c2");

// now takes a cursor as argument
// Like Atoms, Cursors implement the @thi.ng/api/IDeref interface
// and are automatically deref'd by hdom during tree processing
// that means we can just provide the cursor itself as part of the
// component body
const statelessCounter = (_, cursor) =>
    ["button",
        { onclick: (e) => cursor.update((x) => x + 1) },
        `clicks: `, cursor];

const app = ["div",
    [statelessCounter, c1],
    [statelessCounter, c2]
];

Using views & event handlers

Derived views are essentially read-only versions of Cursors and often are a better solution for larger apps, in combination with @thi.ng/interceptors event handling:

import { EventBus, FX_STATE } from "@thi.ng/interceptors";
import { updateIn } from "@thi.ng/paths";

const state = ... // as before

// pre-define event ID
const EV_INC_COUNTER = "inc-counter";

// create event processor with given event handlers
const bus = new EventBus(state, {
    // event handler to update state value at given path
    // handler args: state, event-tuple, bus, interceptor-context
    [EV_INC_COUNTER]: (state, [_, path]) => ({
        [FX_STATE]: updateIn(state, path, (x) => x + 1)
    })
});

// now take context and derived view as arguments
// like cursors, views also support auto-deref
const statelessCounter = (ctx, view) =>
    ["button",
        { onclick: () => ctx.bus.dispatch([EV_INC_COUNTER, view.path]) },
        "clicks: ", view];

// app is now a function to obtain injected context
// (see below)
const app = (ctx) =>
    ["div",
        [statelessCounter, ctx.views.c1],
        [statelessCounter, ctx.views.c2]
    ];

start(
    document.body,
    // root component function now also triggers
    // processing of event bus
    (ctx) => (ctx.bus.processQueue(), app)
    // our arbitrary component context object
    // passed to all embedded component functions
    {
        bus,
        views: {
            c1: state.addView("c1"),
            c2: state.addView("c2"),
        }
    }
);