The #match
feature makes it possible to match an instance of an enumeration (an object with a string type
property, and a value
property).
Let's say we have a Contact
type, which can either be a telephone
type or an email
type. In our template, we'd like to match the contact, and use a component based on what type of contact it is.
import EmailContact from "./EmailContact";
import TelephoneContact from "./TelephoneContact";
import { Component } from "@glimmer/component";
export default class ContactComponent extends Component {
<template>
{{#match @contact}}
{{:when "telephone" as |number|}}
<TelephoneContact @number={{number}} />
{{:when "email" as |email|}}
<EmailContact @email={{email}} />
{{/match}}
</template>
}
In TypeScript:
import EmailContact from "./EmailContact";
import TelephoneContact from "./TelephoneContact";
import { Component } from "@glimmer/component";
type Contact =
| {
type: "telephone";
value: string;
}
| {
type: "email";
value: string;
};
interface ContactSignature {
Args: {
contact: Contact;
};
}
export default class ContactComponent extends Component<{
Args: { contact: Contact };
}> {
<template>
{{#match @contact}}
{{:telephone as |number|}}
<TelephoneContact @number={{number}} />
{{:email as |email|}}
<EmailContact @email={{email}} />
{{/match}}
</template>
}
The Default Clause
A #match
block can have an else
clause. If the #match
block is not matched, the else
clause is used.
Curly Components Get Named Blocks
The initial design of the named blocks feature added named blocks to angle-bracket components, but not to curly components ("This RFC does not propose an extension to curly syntax, although a future extension to curly syntax is expected.")
The #match
syntax is based upon extending named blocks to curly syntax.
Since angle bracket components landed in Ember, curly components that take a block exist philosophically to model control flow. Handlebars already has an else
syntax, and a lot of control flow can be shoehorned into the notion of else
. However, #match
illustrates that general-purpose control flow cannot always be modelled as a pair of "default" and "else" blocks.
The semantics of named blocks in curly components are identical to the semantics of named blocks in angle bracket components.
The syntax of named blocks in curly components aligns with the else
syntax, and do not require a closing tag.
Optional else
Clause
Named Blocks in curly components can have an optional else
clause. This is useful to allow a control-flow construct (like #match
) to take an alternative while still allowing the entire space of block names to be used.
{{#match @contact}}
{{:telephone as |number|}}
<TelephoneContact @number={{number}} />
{{:email as |email|}}
<EmailContact @email={{email}} />
{{else as |value|}}
<p>{{value}}</p>
{{/match}}
The Enumeration Format
The #match
syntax takes an instance of an "enumeration" as its argument, and yields a block based on the type of the enumeration.
An enumeration is an object with:
- a
type
property, whose value is a string - an optional
value
property, with any value
TypeScript Support
This feature is based on TypeScript's discriminated union feature.
This means that the #match
syntax can be trivially translated to TypeScript.
Template Syntax:
{{#match @contact}}
{{:telephone as |number|}}
<TelephoneContact @number={{number}} />
{{:email as |email|}}
<EmailContact @email={{email}} />
{{/match}}
Translated to TypeScript:
switch (this.args.contact.type) {
case "telephone":
TelephoneContact({ number: this.args.contact.value });
break;
case "email":
EmailContact({ email: this.args.contact.value });
break;
}
When translated this way, the values yielded to the blocks get the correct, narrowed type by TypeScript.
Motivation: Asynchronous Data
In the design of Polaris features, we frequently want to model the state of asynchronous data as a reactive data structure.
This allows resources to use asynchronous loading internally, and expose their status as a reactive value.
We want to model this as an enumeration:
type AsyncData<T> =
| {
type: "loading";
}
| {
type: "success";
value: T;
}
| {
type: "error";
value: unknown;
};
It is, of course, possible to use AsyncData
in a template by using #if
:
{{#if (eq @data.type "loading")}}
<p>Loading...</p>
{{else if (eq @data.type "success")}}
<p>Hello {{@data.value.name}}</p>
{{else if (eq @data.type "error")}}
<p>Something went wrong: {{@data.value}}</p>
{{/if}}
💡 This would even narrow correctly in TypeScript, provided that the
eq
helper is translated to===
.
However, this is not ergonomically ideal, in part because it doesn't make it very obvious that @data.value
depends on @data.type
.
The #match
syntax makes this more ergonomic and clearer:
{{#match @data}}
{{:loading}}
Loading...
{{:success as |user|}}
<p>Hello {{user.name}}</p>
{{:error as |error|}}
<p>Something went wrong: {{error}}</p>
{{/match}}
Debouncing
In the [router] design, we'd like to model the existing model hook in the Route
class as a function that returns a Resource<AsyncData>
.
You could specify your loading and error states via #match
.
A Route Today
import { Route } from "@ember/routing";
export default class extends Route {
async model({ user_id }: { user_id: string }) {
const data = await fetch(`/api/users/${user_id}`);
return data.json();
}
}
Loading:
<Loading />
Error:
Something went wrong...
Loaded:
<h1>{{@model.name}}</h1>
<div class="user-details">
<p>{{@model.email}}</p>
<p>{{@model.phone}}</p>
</div>
A Polaris Route
import { Route } from "@ember/routing";
export default class extends Route {
@use model = resource(() => RemoteData(`/api/users/${this.params.user_id}`));
<template>
{{#match this.model}}
{{:loading}}
Loading...
{{:error as |error|}}
<p>Something went wrong: {{error}}</p>
{{:success as |user|}}
<h1>{{user.name}}</h1>
<div class="user-details">
<p>{{user.email}}</p>
<p>{{user.phone}}</p>
</div>
{{/match}}
</template>
}
In this design:
- The route is a component that gets
params
from the router. - The route is not long-lived. When the user navigates away from the route, the component is destroyed, like any other route.
- The "model hook" is no longer special. It's simply a resource.
- If the user navigates away from the route, and the
RemoteData
is still active, it is torn down. It was possible to model this with the model hook, but it basically falls out of making the model a resource and having an idiomaticRemoteData
.
In this example, we don't necessarily want to show the loading spinner immediately. Instead, we may want to wait 100ms or so before showing it. We would also want to wait the same amount of time if #match
gets a new input, and just keep around the old UI if the new data loads quickly.
This example demonstrates that we probably want a version of #match
that's tailored for the case of AsyncData
.
Roughly speaking, it would keep two copies of its input: the current
one and the next
one, and if the next input is loading
, it would wait 100ms before committing it to current
.
💡 If we ship
RemoteData
, we will certainly want to give it the full suite offetch
features. However, we can define the shape of theAsyncData
enumeration as a protocol without shipping a concrete implementation.