We have now at our disposal a way to turn coroutines into web components. We also have a set of higher order functions to manage how a component updates. It is great time to put these small bricks together in an expressive yet simple new UI Framework.
Introduction
The usefulness (and attractiveness) of a framework is definitely more than its codebase: documentation, tools, online content, popularity in the job market and so on. But when I think about its codebase and its API, I see the expression of the human organisation that uses it.
As Reginald “Raganwald” Braithwaite explains it in this very enlightening post, when you have a set of small specialised functions, each of them becomes easy to reason about and a versatile tool. You can then create relations between these functions (e.g. with higher order functions) and have a large number of ways to combine them. In other words: functions give you a lot of expressiveness.
However, this expressiveness may conflict with the perceived complexity. The complexity has shifted to the number of connections you can make between these functions. You have to narrow down the set of possibilities you can create by setting up a framework.
In the end, beyond the personal preferences and biases (which are also the expression of a human organisation), this code:
const productComponent = compose([
withProps(['product']),
withController(productController),
withTemplate
]);
define('my-comp', productComponent(({html}) => {
return (state) => html`<div>...</div>`;
}));
is not necessarily more complex than:
// vuejs option API like
export default ({
name: 'my-comp',
props: ['product'],
methods: productController,
template: `<div>...</div>`
});
Yet, the former seems to have no limits (other than the rules of function composition) and can be daunting to use, while the latter offers a more limited set of options, which tends to look easier to manage.
Framework under construction
Our organisation finds the second API easier to organise the codebase. Let’s now go through the process of building the API we want, by tailoring the various small and specialised functions that we have built in the previous articles.
target
In my previous company, the application we were building had a page that consisted of a list of steps. The user had to go through this list and fill some information to complete each step. We used Vuejs to build our user interfaces, and a step component looked like this:
template part:
<h1></h1>
<p>Welcome </p>
<p v-if="isLoading">loading...</p>
<template v-else>
<p v-if="isDone">Perfect, everything is done</p>
<form ref="form" v-else autocomplete="off" novalidate @submit.prevent="completeStep">
<label>
<span>prop1: </span>
<input name="prop1" type="text" v-model="stepData.prop1" required>
</label>
<label>
<span>prop2: </span>
<input name="prop1" type="text" v-model="stepData.prop2" required>
</label>
<button :disabled="isSubmitting" type="submit">
Submit
</button>
</form>
</template>
script part
import {service} from '../app/step.service.js';
export default {
name: 'Step',
props: {
step: undefined
},
data() {
return {
status: 'todo',
isLoading: true,
isSubmitting: false,
stepData: {}
};
},
computed: {
isDone() {
return this.status === 'done';
},
stepId() {
return this.step?.stepId;
},
userId() {
return this.step?.user?.userId;
}
},
mounted() {
this.fetchState();
},
methods: {
async fetchState() {
try {
this.isLoading = true;
const {status, stepData} = await service.fetchState({stepId: this.stepId, userId: this.userId});
this.status = status;
this.stepData = stepData;
} finally {
this.isLoading = false;
}
},
async completeStep() {
if (!this.$refs.form.reportValidity()) {
return;
}
try {
this.isSubmitting = true;
await service.completeStep({stepId: this.stepId, userId: this.userId, stepData: this.stepData});
this.status = 'done';
} finally {
this.isSubmitting = false;
}
}
}
};
If you look at this snippet, you can easily understand that the component is initially in loading mode while fetching
the data (when it is mounted).
It fetches the data through a service
which needs some parameters passed by a parent component thanks to
the step
prop.
step
props looks like this:
const step = {
stepId: 'my-step',
title: 'A given step',
user: {
userId: 'my-user-id',
name: 'Lorenzofox'
}
};
where some part of the data is used in the template, and some other part is used as parameters when calling the service.
When the fetch is complete, the step is either done
, and there is nothing left to do; or it is still todo
, in
which case the user must fill out and submit the form.
data
refers to a set of internal reactive properties that the template and other parts of the component can use (
under this
), while computed
are read-only properties derived from data
(or props
).
methods
contains the logic and is what I call the controller
. props
, data
and computed
are what I call
the view model.
mental model conversion
How could we build this component within the coroutine model ?
We can first have a controller as defined in the previous article:
export const createStepController = ({$scope, $host}) => {
$scope.status = 'todo';
$scope.isDone = false;
$scope.isLoading = true;
$scope.isSubmitting = false;
$scope.stepData = {};
return {
async fetchState() {
const {stepId, user: {userId}} = $host.step;
try {
$scope.isLoading = true;
const {status, stepData} = await service.fetchState({stepId, userId});
$scope.status = status;
$scope.stepData = stepData;
$scope.isDone = status === 'done';
} finally {
$scope.isLoading = false;
}
},
async completeStep({stepData}) {
const {stepId, user: {userId}} = $host.step;
try {
$scope.isSubmitting = true;
await service.completeStep({stepId, userId, stepData});
$scope.stepData = stepData;
$scope.status = 'done';
$scope.isDone = false;
} finally {
$scope.isSubmitting = false;
}
}
};
};
What the controller returns is quite similar to the methods
section of the Vuejs component while $scope
would be
the equivalent of data
and computed
.
However, we have not yet the notion of computed
and we have to remember to compute isDone
whenever status
changes.
The props
are accessible on the $host
and can easily be defined using the withProps
controller.
However, you should have noticed that we have to delay the calls to the getter on $host
in each method: this is
because, if you remember,
the controller is instantiated when the component is being constructed, while the property is set after it is mounted.
We will assume that it is not a problem for now.
Now let’s build the component itself with a generator function:
import {render, html} from 'lit-html';
export function *Step({$host, controller}) {
// constructed
let state = yield;
controller.fetchState();
while (true) {
const templateEntry = template({...getViewModel(state), onSubmit});
render(templateEntry, $root);
state = yield;
}
function onSubmit(ev) {
ev.preventDefault();
const {target: form} = ev;
if (!form.reportValidity()) {
return;
}
controller.completeStep({
userId,
stepId,
stepData: Object.fromEntries(new FormData(form).entries())
});
}
};
const getViewModel = ({properties, $scope}) => ({
...properties,
...$scope
});
const template = ({isSubmitting, isLoading, isDone, stepData, step, onSubmit}) => {
return html`...`;
};
We first wait for the component to mount before calling the controller to fetch data (like the mounted
hook in the vue example).
All the injected namespaces are merged into a single viewModel
and used as input to a template
function, which is nothing more than the template part of the vue example, but with lit-html.
We can now glue all the pieces together:
const stepComponent = compose([withController(createStepController), withProps(['step'])]);
define('app-step', stepComponent(Step));
// and to boot the app
import {html, render} from 'lit-html';
const step = {
stepId: 'my-step',
title: 'A given step',
user: {
userId: 'my-user-id',
name: 'Lorenzofox'
}
};
render(html`
<app-step debug .step=${step}></app-step>`, document.getElementById('app'));
Abstract away the implementation details
The next step is to abstract away the coroutine-based mental model and expose a single function that defines the components using the targeted API:
defineComponent({
tag: 'my-comp',
// data
data: () => ({
prop1: 'value1'
}),
// computed
computed:{
derived(viewModel){
viewModel.foo + 42
}
},
// lifecycles
mounted({viewModel, controller}) {
controller.fetchData();
},
controller({viewModel}){
return {
async fetchData(){
//
}
}
},
template({viewModel, controller}){
return html`...`
}
})
This is basically the same component definition as the vue API, except that we have functions of viewModel
and controller
instead of relying on the this
component instance. Again, this is just part of our organisation preferences.
We can first transform the Step
generator, so it follows an abstract structure:
import {render} from 'lit-html';
export const withView = ({mounted, template, getViewModel}) => function*({$host, controller}){
let viewModel = getViewModel(yield);
mounted({viewModel, controller});
while(true){
render(template({viewModel, controller}), $host);
viewModel = getViewModel(yield);
}
};
We can now write the Step
function with this abstract withView
function
export const Step = withView({
mounted({controller}){
controller.fetchState();
},
template({controller, viewModel}) {
const {isLoading /* ... */} = viewModel;
return html`...`;
function onSubmit(ev) {
// ...
controller.completeStep(/* .. */);
}
},
getViewModel({properties, $scope}) {
return {
...properties,
...$scope
}
}
});
It would also be better to normalise the controller itself:
import {service} from './step.service.js';
export const controller = ({viewModel}) => {
return {
async fetchState() {
const {stepId, user: {userId}} = viewModel.step;
try {
viewModel.isLoading = true;
const {status, stepData} = await service.fetchState({stepId, userId});
viewModel.status = status;
viewModel.stepData = stepData;
} finally {
viewModel.isLoading = false;
}
},
async completeStep({stepData}) {
const {stepId, user: {userId}} = viewModel.step;
try {
viewModel.isSubmitting = true;
await service.completeStep({stepId, userId, stepData});
viewModel.status = 'done';
} finally {
viewModel.isSubmitting = false;
}
}
};
};
It is now a function of viewModel
(no more $scope
). For the same reason as before, we still have to read step
(which comes from the properties) on the view model as late as possible.
For the computed (isDone
), we assume it is handled somewhere else, in the defineComponent
function.
Finally, defineComponent
will put all the parts together.
export const defineComponent =({
tag,
props,
data,
computed,
mounted,
controller,
template
}) => {
const viewModel = buildViewModel({computed, data});
const pipeline = compose([
withInjectables({
properties: viewModel,
$scope: viewModel,
viewModel
}),
withProps(props),
withController(({$scope}) => controller({viewModel: $scope}))
]);
define(tag, pipeline(withView({mounted, template, getViewModel: () => viewModel})));
};
const buildViewModel = ({computed, data}) => {
const viewModel = data();
return Object.defineProperties(viewModel, mapValues((computedFn) => ({
enumarable: true,
get(){
return computedFn(viewModel);
}
}), computed));
};
const withInjectables = (injectables) => (gen) => function *(args) {
yield* gen({
...injectables,
...args
});
};
If you remember: withController
and withProps
can have their meta object injected. We first build this meta object within buildViewModel
using the provided data
function. We then add the computed
on this meta object thanks to property descriptors:
these properties are simple getters(readonly), functions of the view model.
We use yet another higher order function withInjectables
to ensure that the view model is injected into the other controllers under the correct name parameter.
We need to adjust the parameter passed to the controller function of withController
because it has injected a variable named $scope
, whereas we have normalised the controller function signature to viewModel
.
We could have directly used the viewModel
variable from the closure but withController
passes a Proxy to add reactivity: hence the remapping of this parameter.
Finally, we can use our newly created withView
function, passing a getViewModel
function which always returns the reference of the view model.
Great!
Conclusion
Thanks to a small set of specialised functions, we were able to create a completely different API based on the component representation our organisation is familiar with.
The process was actually very simple, and again shows the full power of functions and composition. Generators have disappeared and are now an implementation detail, but they have proved their versatility in building higher level APIs.
We can actually do way better.