3 Understand VComponent-based Web Components
Oracle JET provides you with a web component API,
VComponent
, to create web components that use virtual DOM rendering.
The web components that you create using VComponent use virtual DOM rendering. For those of you who previously used the Composite Component Architecture (CCA) to develop web components, you’ll see many differences. Those of you who are familiar with the Preact library that underpins the Oracle JET virtual DOM architecture will see some familiar concepts. This chapter attempts to introduce you to the concepts that you'll need to know to develop VComponent-based web components.
One difference to note is that unlike CCA-based web components,
VComponent-based web components do not use Knockout or its built-in expression evaluator
to evaluate expressions. Instead, VComponent-based web components use JET’s
CspExpressionEvaluator
to ensure that expressions you use comply
with Content Security Policy. CspExpressionEvaluator
supports a limited
set of expressions to ensure compliance with Content Security Policy. Familiarize
yourself with the syntax that JET's CspExpressionEvaluator
supports
when using expressions in your VComponent-based web component. See the
CspExpressionEvaluator API documentation.
The JET tooling assists you with creating, packaging, and publishing web components. Usage of the JET tooling remains the same as for CCA-based web component development, but the output differs. We'll go through the creation of a standalone VComponent-based web component and a series of web components to include in a JET Pack in the next chapter.
For now, let’s look at usage of VComponent
to create web components,
assuming that you have already acquired the Prerequisite Knowledge that we described in the introductory chapter of this guide.
Note:
You can complement your reading of this chapter by also reading the VComponent entry in the API Reference for Oracle® JavaScript Extension Toolkit (Oracle JET) and the Oracle JET VComponent Tutorial.Hello VComponent, an Introduction
You write a VComponent-based web component as a TypeScript module in a file
with the .tsx
file extension.
The example that follows shows a VComponent class named
HelloWorld
with a custom element name of
hello-world
in a file named
hello-world.tsx
. Note the following about the
entries in the hello-world.tsx
file:
- JSX elements express the content of the virtual DOM tree
(
<p>{props.message}</p>
). - The
h
function, imported from the preact module, turns the JSX elements into virtual DOM elements.
import { ExtendGlobalProps, registerCustomElement } from "ojs/ojvcomponent";
import { h, ComponentProps, ComponentType } from "preact";
import componentStrings = require("ojL10n!./resources/nls/hello-world-strings");
import "css!./hello-world-styles.css";
type Props = Readonly<{
message?: string;
}>;
/**
*
* @ojmetadata version "1.0.0"
* @ojmetadata displayName "A user friendly, translatable name of the component"
* @ojmetadata description "A translatable high-level description for the component"
*
*/
function HelloWorldImpl({ message = "Hello from hello-world" }: Props) {
return <p>{message}</p>;
}
export const HelloWorld: ComponentType<ExtendGlobalProps<ComponentProps<typeof HelloWorldImpl>>>
= registerCustomElement("hello-world", HelloWorldImpl);
The Oracle JET tooling helps you create VComponent web components by
generating a template .tsx
file plus additional files and
folders with resources to support the component. The example just shown with
the custom element name of hello-world
was created by the
following command:
ojet create component hello-world
If you want to create a VComponent-based web component in an app that
does not use the virtual DOM architecture, you need to include
--vcomponent
in the command to create the component
(ojet create component hello-world
--vcomponent
). The Oracle JET tooling also supports
the creation of class-based web components if you append the
class
option to the --vcomponent
parameter (ojet create component hello-world
--vcomponent=class
). The default behavior is to create
function-based VComponents.
Irrespective of the type of VComponent that you create (class or
function), the tooling generates these files in the directory referenced by
the components
property in the
appRootDir/oraclejetconfig.json
file. By default,
the value of the components
property is also
components
.
appRootDir/components/hello-world/
| loader.ts
| hello-world-styles.css
| hello-world.tsx
| README.md
+---resources
+---themes
Readers who previously developed CCA-based web components will
recognize the loader.ts
file that the Oracle JET tooling
includes so that the component can be used by the Component Exchange, Oracle
Visual Builder, and the Oracle JET tooling itself. For a VComponent-based
web component, the loader.ts
file includes an entry to
export the VComponent module, as in the following example:
export { HelloWorld } from "./hello-world";
Once you build a VComponent web component, you can import it into
the app where it is to be used. The following example demonstrates how you
import our example component into the content
component of
an app that was scaffolded using the virtual DOM architecture starter
template:
import { h } from "preact";
import { HelloWorld } from "hello-world/loader";
export function Content() {
return (
<div class="oj-web-applayout-max-width oj-web-applayout-content">
<HelloWorld />
</div>
);
}
![Image described in surrounding text Image described in surrounding text](img/vdom-helloworld.png)
Metadata for VComponents
JET metadata expresses information that may be useful to both tools and consumers of the VComponent-based web components that you create.
You write metadata in the VComponent's module class. You’ll have seen
examples of this metadata in the HelloWorld VComponent that we introduced
earlier. Specifically, the HelloWorld VComponent included a TypeScript
decorator, @customElement("hello-world")
, to add custom
element behavior to the VComponent at runtime, and it is also used at build
time as a source of the component’s “name”
metadata. The
other example is the use of the @ojmetadata
doc annotation
where a series of entries provide version, display name, and description
information, as in the following example:
* @ojmetadata version "1.0.0"
* @ojmetadata displayName "A user friendly, translatable name of the component"
* @ojmetadata description "A translatable high-level description for the component"
You’ll notice that each @ojmetadata
annotation
specifies a single name/value pair. The values must be valid JSON values. As
shown above, string values should be double-quoted. Object, array, and
primitive values can be specified directly within the annotation (without
quotes). You can also extend the metadata to append extra information in an
extension field, as shown by the following example.
* @ojmetadata extension {
* vbdt: {
* someVisualBuilderDesignTimeField: true
* }
* }
For reference information about JET Metadata, see JET Metadata.
Nest VComponents
Custom element-based VComponents can be embedded directly into HTML.
This allows you to integrate VComponents into existing Oracle JET content,
including into composite components, oj-module content, or pages authored in
Oracle Visual Builder. In addition to being hosted within HTML, VComponents
can be nested inside of other VComponents. A parent VComponent can reference
a child VComponent using the component class name. In the following example,
a VComponent class, HelloParent
, nests a child VComponent,
Hello
.
import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
import { h, Component, ComponentChild } from "preact";
import { Hello } from "oj-greet/hello/loader";
type Props = {
message?: string;
};
/**
* @ojmetadata pack "oj-greet"
* ...
*/
@customElement("oj-greet-hello-parent")
export class HelloParent extends Component<ExtendGlobalProps<Props>> {
static defaultProps: Partial<Props> = {
message: "Hello from oj-greet-hello-parent!",
};
render(props: Props): ComponentChild {
return (
<div>
<p>{props.message}</p>
<p>The HelloParent VComponent nests the Hello VComponent class in the
next line:</p>
<Hello />
</div>
);
}
}
The resulting content in the HTML is:
<div class="oj-web-applayout-max-width oj-web-applayout-content">
<oj-greet-hello-parent class="oj-complete">
<div>
<p>Hello from oj-greet-hello-parent!</p>
<p>The HelloParent VComponent nests the Hello VComponent class in the next line:</p>
<oj-greet-hello class="oj-complete"><p>Hello from oj-greet-hello!</p></oj-greet-hello>
</div>
</oj-greet-hello-parent>
</div>
An <oj-greet-hello>
custom element ends up in
the live DOM.
VComponent Properties
Properties are read-only arguments of a VComponent class that you pass into an instance of the VComponent.
Properties that you declare may also be passed to web components as HTML attributes. Essentially, the properties of a VComponent API component module are like function arguments in JSX and attributes in HTML usages.
Declare VComponent Properties
You declare a VComponent property through a type alias that is, by
convention, named Props
.
Each field in the type represents a single public component property. A
field specifies the property's name, type, and whether the value for the
field is optional or required. Default values are specified in the
static defaultProps
field on the component
class.
In the following example, we declare a single property
(preferredGreeting
) of type string. TypeScript’s
optional indicator (?
) identifies it as an optional
property, and the default value of Hello
is specified in
the static defaultProps
field.
One subtle requirement that may be easy to miss: to associate the
properties class with the VComponent implementation, you need to specify the
class as the value of the VComponent's first type parameter (export
class WithProps extends
Component<ExtendGlobalProps<Props>>
).
import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
import { h, Component, ComponentChild } from "preact";
type Props = {
preferredGreeting?: string;
};
/**
* @ojmetadata pack "oj-greet"
* ...
*/
@customElement("oj-greet-with-props")
export class WithProps extends Component<ExtendGlobalProps<Props>> {
static defaultProps: Partial<Props> = {
preferredGreeting: "Hello",
};
render(props: Props): ComponentChild {
return <p>{props.preferredGreeting}, World!</p>;
}
}
Reference Properties in JSX
To work with the properties, VComponent requires that you first associate the property class with the VComponent instance implementation.
import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
import { h, Component, ComponentChild } from "preact";
type Props = {
preferredGreeting?: string;
};
/**
* @ojmetadata pack "oj-greet"
*/
@customElement("oj-greet-with-props")
export class GreetWithProps extends Component<ExtendGlobalProps<Props>> {
static defaultProps: Partial<Props> = {
preferredGreeting: "Hello from oj-greet-with-props!"
};
render(props: Readonly<Props>): ComponentChild {
return <p>{props.preferredGreeting}</p>;
}
}
Access Properties
You can access declared properties in the VComponent API component
implementation through a special object: this.props
. An
example of this usage can be found at line 12, where the
this.props
field extracts the value of the
preferredGreeting
property into the variable
greeting
. This variable subsequently influences the
state of the rendered virtual DOM tree, where the value of the
preferredGreeting
property gets embedded into the
virtual DOM at line 16.
One point to keep in mind is that the property values in
this.props
are always defined by the consumer of
the VComponent API component. In the HTML case, this.props
is populated based on attribute/property values specified on the custom
element by the application. In the case where the VComponent API component
is used within a parent VComponent API component, the property values are
provided by the parent component. A VComponent API component implementation
can read these property values, but must never mutate the
this.props
object.
Reference Properties of a Child Component in JSX
VComponent API custom elements can also be embedded inside of other parent VComponent API custom elements.
As was described in Nest VComponents, a VComponent API component parent can refer to a child using the VComponent API component’s implementation class name directly. Inside of JSX, always specify component properties using their camelCase property names.
Here is an example of a VComponent,
GreetWithPropsParent
, that demonstrates this:
import { h, Component } from "preact";
import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
import { GreetWithProps } from "oj-greet/with-props/loader";
/**
* @ojmetadata pack "oj-greet"
* ...
*/
@customElement("oj-greet-with-props-parent")
export class GreetWithPropsParent extends Component<ExtendGlobalProps<Props>> {
render() {
return (
<div>
<GreetWithProps preferredGreeting="Hola" />
</div>
);
}
}
Note how the sample uses the preferredGreeting
property name, not the preferred-greeting
attribute
name.
Type-Checking Support
Although the use of different naming conventions between HTML and JSX markup appears confusing at first, it is important to use only property names within JSX to maintain type checking.
When specifying a JSX element like this:
<oj-greet-with-props preferredGreeting="Hey there"/>
Or this:
<GreetWithProps preferredGreeting="Hola"/>
The properties on each JSX element populate a props
object that
eventually ends up populating the child component's
this.props
field. The type of this
props
object is based on the child VComponent API
component instance's props
type parameter. So using the
property names as declared by the VComponent API component instance's
property type, ensures type checking (and catching errors) happens in the
parent component's JSX.
Global HTML Attributes
The naming convention of camelCase supports referencing component properties
from within JSX. Ideally, this same convention can work for global HTML attributes,
such as id
or tabIndex
. However, not all global
HTML attributes are exposed as properties. For example, aria-
and
data-
attributes do not have property
equivalents.
This leads to the following rules for working with global HTML properties/attributes:
- If the global HTML attribute is available as a property, use the property name.
- If the global HTML attribute is not available as a property, use the attribute name.
In many cases, global HTML attribute names will be identical to the property name
(such as id
, title
, and
style
). However, there are some cases where the
attribute and property name differ, or where the property name requires a
specific case-folding. For example, since attributes are case insensitive,
HTML allows any capitalization of the tabindex
attribute.
However, JSX requires that you use the actual property name
tabIndex
:
protected render() {
// While "tabindex" is a valid way to specify the tab index
// in an HTML document, in JSX, the property name "tabIndex"
// must be used.
return <div tabIndex="0" />
}
There is one exception to the rule that governs property name
references. Although the property name for specifying style classes is
className
, this name is not commonly known.
VComponent
allows use of the more familiar
attribute name class
:
protected render() {
// Use "class" instead of "className"
return <div class="awesome-class" />
}
Children and Slot Content
In addition to exposing properties, components can also allow children to be passed in. With VComponent API custom elements, children are specified in one of two ways:
- As direct children, with no
slot
attribute. This is also known as the default slot. - As a named slot, with the name set through the
slot
attribute.
Components can leverage both of these approaches. For example, the
following oj-button
element is configured both with default
slot content, as well as content in the startIcon
named
slot:
<oj-button>
<span>This is default slot content</span>
<span slot=”startIcon”>This is named slot content</span>
</oj-button>
The VComponent API supports authoring of custom elements that expose default slots, named slots, or both.
Default Slots
VComponent API favors the use of code constructs over external metadata for defining a component’s public API. There is no need to declare a VComponent API custom element children/slot contract through JSON metadata; instead default slots are added by writing code.
The children/slot contract for the VComponent API custom element is defined
by adding fields to a Props
class. In particular, you
indicate that a component can accept default slot content by declaring a
children property of type ComponentChildren
:
import { h, Component, ComponentChildren } from 'preact';
type Props = {
preferredGreeting?: string;
children?: ComponentChildren;
}
And, you can then associate the Props
class with
the VComponent through the Props
type parameter:
@customElement(‘oj-greet-with-children’)
export class GreetWithChildren extends Component<ExtendGlobal<Props>> {
}
Once this is done, any default slot children will be made available
to the VComponent API component implementation through
props.children
. This is true regardless of whether
the component implementation is used as a custom element within an HTML
document, a custom element within JSX, or through the VComponent component
implementation class within JSX.
The VComponent component implementation is free to place the default
slot children anywhere within the component’s virtual DOM tree. For example,
a VComponent API button likely would place these children inside of an HTML
<button>
element:
protected render() {
return <button> { props.children } </button>;
}
Named Slots
Like the default slot, named slots are also declared as fields on the
props
class.
Named slot declarations must adhere to two conventions:
- The named slot field must use the
Slot
type. - The name of the field must match the slot name.
The declaration for a slot named startIcon
looks
like this:
import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
import { h, Component, ComponentChild } from "preact";
import "ojs/ojavatar";
import { GreetWithChildren } from 'oj-greet/with-children/loader'
type Props = {
startIcon?: Slot;
}
/**
* @ojmetadata pack "oj-greet"
* @ojmetadata dependencies {
* "oj-greet-with-children": "^1.0.0"
* }
*/
@customElement('oj-greet-with-children-parent')
export class GreetWithChildrenParent extends Component<ExtendGlobalProps<Props>> {
render() {
return (
<div>
<p>This child is rendered as a VComponent class:</p>
<GreetWithChildren startIcon={<oj-avatar initials="HW" size="xs" />}>
World
</GreetWithChildren>
</div>
);
}
When the VComponent is referenced through its class, named slot content is provided by specifying virtual DOM nodes directly as values for slot properties, as demonstrated in the following parent VComponent.
import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
import { h, Component, ComponentChild } from "preact";
import "ojs/ojavatar";
import { GreetWithChildren } from "oj-greet/with-children/loader";
/**
* @ojmetadata pack "oj-greet"
* @ojmetadata dependencies {
* "oj-greet-with-children": "^1.0.0"
* }
*/
@customElement("oj-greet-with-children-parent")
export class GreetWithChildrenParent extends Component<ExtendGlobalProps<Props>> {
render() {
return (
<div>
<p>This child is rendered as a VComponent class:</p>
<GreetWithChildren startIcon={<oj-avatar initials="HW" size="xs" />}>
World
</GreetWithChildren>
</div>
);
}
}
Refresh Custom Elements with Dynamic Children and Slot Content
Virtual DOM architecture provides a Remounter component to make sure that a custom element re-renders correctly when its children or slot content changes dynamically.
A concrete example demonstrates when to use the Remounter component. In the following
example, the oj-button
element’s display of a start icon depends on the value
of the showStartIcon
property. We want to ensure that the
oj-button
renders the start icon in the correct location when the
showStartIcon
property changes.
In the MVVM architecture, one could use the oj-button
’s
refresh
method after mutating the child content to ensure the component
incorporates the child DOM changes.
type Props = {
showStartIcon?: boolean
}
function ButtonStartIconSometimes(props: Props) {
return (
<oj-button>
{ props.showStartIcon && <span slot="startIcon" class="some-icon-class" /> }
Click Me!
</oj-button>
)
}
A more appropriate approach for the VDOM architecture is to assign a unique key that reflects the oj-button
showStartIcon
property’s different states. In the above example, assigning a
unique key is straightforward, as there are only two possible states. However, in some cases
producing a unique key for all possible children states is more challenging. For these more
challenging cases, use the Remounter component to wrap a single custom element child and
generate a unique key based on the current set of children. The following revised example
demonstrates how you use the Remounter component to ensure that the oj-button re-renders with
the start icon in the correct location:
import { h } from 'preact';
import { Remounter } from 'ojs/ojvcomponent-remounter';
import 'ojs/ojbutton';
type Props = {
showStartIcon?: boolean
}
function ButtonStartIconSometimes(props: Props) {
return (
<Remounter>
<oj-button>
{ props.showStartIcon && <span slot="startIcon">Start Icon</span> }
Click Me!
</oj-button>
</Remounter>
)
}
export { ButtonStartIconSometimes };
Note that you only need to use the Remounter component when configuring custom elements where the number of types of children change across renders.
Template Slots
In addition to simple, non-contextual slots, JET components also support slots that can receive context. These are called template slots.
Template slots are typically found in collection components that iterate
over a data set, stamping out content for each item or row. For example,
<oj-list-view>
exposes an
itemTemplate
slot that controls how the content of
each list item renders. Within HTML, a template
element
with a slot
attribute specifies a template slot, as in the
following example:
<oj-list-view data="[[ items ]]">
<template slot="itemTemplate" data-oj-as="item">
<div>
<oj-bind-text value="[[item.data.value]]"></oj-bind-text>
</div>
</template>
</oj-list-view>
VComponent API custom elements can also expose template slots. To understand how this works, consider a greeting component that takes an array of names to greet and renders a greeting for each name. The property declaration might look like this:
class Props {
names: Array<string>
}
It is possible to just iterate over the names and render the content for each item:
protected render() {
return (
<div>
{ this.props.names.map(name => <div>Hello, {name}!</div>) }
</div>
);
}
With the above approach, the decision about how to render each greeting would be hardcoded into the component implementation. Instead, this can be made more flexible by exposing a template slot that allows the app to customize how each greeting is rendered.
Similar to simple, non-contextual slots, template slots are declared
as properties with a well known type: TemplateSlot
. Let's
take a look at this type alias:
export type TemplateSlot<Data> = (data: Data) => Slot;
The TemplateSlot
is a generic function type that
accepts a single argument: the data to use when rendering a specific
instance of the template. The type of this data is defined through the
Data
type parameter, which must be specified when
the TemplateSlot
property is declared.
greetingTemplate
slot:
oj-greet/hello-many.tsx:
1 import { h, Component } from 'preact';
2 import { customElement, ExtendGlobalProps, TemplateSlot } from 'ojs/ojvcomponent';
3
4 export type GreetingContext = {
5 name: string;
6 }
7
8 type Props = {
9 names: Array<string>;
10 greetingTemplate?: TemplateSlot<GreetingContext>;
11 }
12
13 /**
14 * @ojmetadata pack "oj-greet"
15 */
16 @customElement('oj-greet-hello-many')
17 export class GreetHelloMany extends Component<ExtendGlobalProps<Props>> {
18 render() {
19 return (
20 <div>
21 {
22 this.props.names.map((name) => {
23 return this.props.greetingTemplate?.({ name }) ||
24 <div>Hello, { name }!</div>
25 })
26 }
27 </div>
28 );
29 }
30 }
This sample declares the greetingTemplate
slot at
line 10. Note that the Data
type parameter must be an
object type. The sample uses the GreetingContext
type as
declared at line 4.
The template slot (if non-null) is invoked for each item in the names
array at line 23. The sample passes in an object of type
GreetingContext
with each invocation.
Alternatively, if no slot is provided, it returns the default content at
line 24.
Provide Template Slot Content within HTML
After you expose the template slot in the VComponent implementation, then
within HTML, you provide slot content the same as any JET custom element: by specifying a
<template>
element with a slot
attribute. Within the
template
element, you use JET binding expressions and elements to render the
desired greeting:
<oj-greet-hello-many names="[[ ['Joel', 'Mike', 'Jonah' ] ]]">
<template slot="greetingTemplate" data-oj-as="greeting">
<div>
Hi, <oj-bind-text value="[[ greeting.name ]]"></oj-bind-text>!
</div>
</template>
</oj-greet-hello-many>
Template Slots in JSX
When rendering a component in JSX through its VComponent API component class
(such as <GreetHelloMany>
), template slots are passed in as
functions that adhere to the TemplateSlot contract. This means you must implement
template slots as functions that take some data, and return either a single virtual
DOM node or an array of nodes.
This might look something like:
<GreetHelloMany names={names}
greetingTemplate={ (data) => <div>Hello, { data.name}!</div> }
Of course, you can also reference the GreetHelloMany
component
by using its custom element tag name.
As the previous HTML sample shows, custom element template slots are specified
using JET binding expressions (such as value="[[ greeting.name
]]"
) and elements (such as oj-bind-text
)
inside a <template>
element. While this approach fits in
nicely within an HTML document alongside other content that is configured
using JET bindings, it doesn't fit well inside of a JSX render function.
Within JSX, rather than configuring template slot content using JET binding
syntax, JSX syntax is preferred.
To allow template slot content to be specified using JSX-based render
functions, VComponent API introduces a special, VComponent-specific property
on the <template>
element: the
render
property. The type of this property is
TemplateSlot
.
This allows us to configure template slots on custom elements using JSX-based render functions, for example:
<oj-greet-hello-many names={names}>
<template slot="greetingTemplate"
render={ (data) => <div>Hello, { data.name}!</div> }/>
</oj-greet-hello-many>
Note that you still need to specify a
<template>
element with a
slot
attribute. Rather than configuring the
template slot with JET's binding syntax, instead specify a
TemplateSlot
function that returns virtual DOM.
A more complete parent component shows this:
oj-greet/hello-many-parent.tsx:
1 import { h, Component } from 'preact';
2 import { customElement, GlobalProps } from 'ojs/ojvcomponent';
3 import "ojs/ojavatar";
4 import { GreetHelloMany, GreetingContext } from 'oj-greet/hello-many/loader';
5
6 /**
7 * @ojmetadata pack "oj-greet"
8 */
9 @customElement('oj-greet-hello-many-parent')
10 export class GreetHelloManyParent extends Component<ExtendGlobalProps<Props>> {
11 render() {
12
13 const names = [ 'Joel', 'Mike', 'Jonah' ];
14
15 return (
16 <div>
17 <p>This child is rendered as a custom element:</p>
18 <oj-greet-hello-many names={names}>
19 <template slot="greetingTemplate" render={ this.renderGreeting }/>
20 </oj-greet-hello-many>
21 <br />
22 <p>This child is rendered as a VComponent class:</p>
23 <GreetHelloMany names={names} greetingTemplate={ this.renderGreeting }/>
24 </div>
25 );
26 }
27
28 private renderGreeting(data: GreetingContext) {
29 const name = data.name;
30 const firstInitial = name.charAt(0);
31 const greeting = name.length < 5 ? 'Hey' : 'Hi';
32
33 return (
34 <p class="centerAlignVertical">
35 <oj-avatar size="xxs" initials={ firstInitial } />
36 {greeting}, { name }!
37 </p>
38 );
39 }
40 }
In the above example, the render
property provides
JSX-based content for the <oj-greet-hello-many>
custom element, which happens to be implemented with VComponent API.
However, this property can also be used when configuring template slot
content for any JET component. For example, you can configure the
<oj-list-view> itemTemplate
slot as follows,
even though this custom element is not implemented with the VComponent API:
protected render() {
<oj-list-view data={ this.props.items }>
<template slot="itemTemplate"
render={ ( item ) => { return <div>{ item.data.value }</div> } } />
</oj-list-view>
Understand Events and Actions
Two terms seem interchangeable at first, but in VComponent API,
event
and action
have two distinct
meanings:
event
specifically refers to DOM Events that are dispatched by calling todispatchEvent
.action
is a higher-level abstraction for event-like APIs, which may or may not actually involve dispatching an Event at the DOM level.
This distinction arises due to the fact that VComponent API component instances can be used in two ways:
- As a custom element, using the string tag name.
- As a VComponent API component, using the component implementation class.
When a VComponent API component is used as a custom element, invoking an action results in the dispatch of a DOM event.
However, when referencing a VComponent API component through its implementation class, no DOM event is created or dispatched. Instead, the action callback provided by the parent component is invoked directly.
To support these different usage models, a higher level abstraction than DOM events is required. VComponent API actions provide that abstraction.
For simplicity, usage of the term action refers to the general behavior by which VComponent API component instances notify the outside world of activity. Whereas usage of the term event is reserved specifically for DOM events that are dispatched by custom (or plain old HTML) elements.
Listeners
Event listeners are functions that take a DOM event
and have no return value. VComponents can listen for and respond to standard HTML
events plus custom events on custom elements.
The naming convention that you use for the event listener differs depending on whether you listen for a standard HTML event or custom event.
For standard HTML events, such as click
,
change
, mouseover
, add a property name
that uses the naming convention:
on<UpperCaseStandardEventName>
. The following
example shows you how to register an event listener for a
click
event.
render() {
return <div onClick={this._handleClick}>Click Me!</div>
}
For custom events such as <oj-button>
's
ojAction
event, use the
on<customEventName>
naming convention. The
following example shows you how to register an event listener for an
ojAction
event.
protected render() {
return <oj-button onojAction={this._handleAction}>Click Me!<oj-button>
}
Note how the first character of the custom event name is not capitalized compared
to the standard event (onojAction
versus
onClick
).
There are a number of ways to enable event listener access to a VComponent
instance. You can, for example, explicitly call bind(this)
on the event listener function or, alternatively, use one of the following
approaches:
- Define and use an arrow function inline in the
render()
method. - A class method can be bound and saved away in the constructor.
- An arrow function can be declared and stored in a class field.
The last two options avoid creating a new function on each call to the
render()
method and, by using the same function
instance across all render()
methods, avoid virtual DOM
diffs that cause DOM addEventListener
and
removeEventListener
calls on each call to the
render()
method. The class field approach
(private _handleEvent
), demonstrated in the
following example, is slightly more concise.
import { h, Component } from "preact";
import { customElement, GlobalProps } from "ojs/ojvcomponent";
@customElement("oj-greet-with-listeners")
export class GreetWithListeners extends Component<GlobalProps<Props>> {
render() {
return (
<div>
<div onClick={this._handleEvent}>
<p>Hello, World!</p>
</div>
<oj-button onojAction={this._handleEvent}>Click Me</oj-button>
</div>
);
}
private _handleEvent = (event: Event) => {
console.log(`Received ${event.type} event`);
};
}
Actions
We need to make a distinction between event
and
action
because of the different behavior that occurs when
you use a VComponent as a custom element or a component class.
In a VComponent, an event
refers to DOM Events that
are dispatched through a call to dispatchEvent while
an action
is a higher-level abstraction for event-like
APIs, which may or may not actually involve dispatching an event at the DOM
level. When you use a VComponent as a custom element, invoking an action
results in the dispatch of a DOM event while no DOM event is created or
dispatched when you use the component class. Instead, for the latter case,
the action callback provided by the parent VComponet is invoked directly.
VComponent action provides a higher-level abstraction than DOM events that
is needed to support these two usage models.
Declare Actions
You need to be able to define actions to dispatch in response to VComponents-defined events.
The following example demonstrates how you add a
responseDetected
event action to a VComponent that
is dispatched in response to a user action, such as a click. You add a field
to the Props
class. The field that you add must follow the
standard event listener property naming convention
(on<UpperCaseEventName>
) and it must use the
Action type defined by the ojs/ojvcomponent
module.
import { customElement, ExtendGlobalProps, Action } from 'ojs/ojvcomponent';
type Props = {
preferredGreeting?: string;
// This is an action declaration:
onResponseDetected?: Action;
}
Dispatch Actions
VComponent’s Action type is a callback function.
export type Action<Detail extends object = {}> = (detail?: Detail) => void;
To dispatch an action, the VComponent invokes the Action-typed property as a
function. Actions are typically dispatched in response to some underlying
event. In the following example, the VComponent instance dispatches the
responseDetected
action in response to a click:
private _handleClick = (event: MouseEvent) => {
this.props.onResponseDetected?.();
}
Note that the consumer of the component is not required to provide a value for
onResponseDetected
. As a result, we need to guard
against a null value for this.props.onResponseDetected
. To
do this, we can take advantage of TypeScript's support for the optional
chaining operator (?.
). This allows us to invoke the action
callback if it is provided but short-circuit if not, without the need for a
more verbose null check.
Respond to Actions
The response to an invoked action depends on usage.
If the VComponent is used as a custom element (either within an HTML
document or within JSX in a parent VComponent), the VComponent framework
creates a DOM CustomEvent that it dispatches through the custom element. The
event type is derived from the
name of the Action property by removing the on prefix and lower casing the
first letter. For example, the onResponseDetected
action
results in the dispatch of a responseDetected
DOM event
type.
If the VComponent is being used by a parent VComponent and is referenced by its class name rather than the custom element name, no DOM event is created. If the parent VComponent provides a value for the Action property, this is invoked directly.
In JSX, action callbacks are always specified using the Action property name. This is true regardless of whether the parent references the child VComponent by its custom element name of class:
<p>This child is rendered as a custom element:</p>
<oj-greet-with-actions onresponseDetected={this.handleResponse}/>
<p>This child is rendered as a VComponent class:</p>
<GreetWithActions onResponseDetected={this.handleResponse}
However, if the custom element lives within an HTML document, event listeners are typically registered with JET's event binding syntax. This might look something like:
<oj-greet-with-actions on-response-detected="[[ expressionPointingToEventHandler ]]">…
</oj-greet-with-actions>
Though it is also possible to call the DOM addEventListener API directly.
Action Payloads
You may have noticed that Action is a generic type with a Detail type parameter. The Detail type parameter is useful when the action needs to deliver additional information beyond just the action type.
For example, our Greeting component may want to include a flag along with the
responseDetected
action to indicate urgency. This
would be declared using the Detail type parameter as follows:
type Props = {
preferredGreeting?: string;
onResponseDetected?: Action<{
urgent: boolean;
}>;
}
When invoking the action, the detail payload is passed in as an argument to the action callback, as in the following example:
private _handleClick = (event: MouseEvent) => {
// Pass in a detail payload. Determine urgency based on
// number of clicks.
this.props.onResponseDetected?.({
urgent: event.detail > 1
});
}
The Detail
type parameter can also be specified using a type
alias, as in the following example:
export type ResponseDetectedDetail = {
urgent: boolean;
};
type Props = {
preferredGreeting?: string;
onResponseDetected?: Action<ResponseDetectedDetail>;
};
On the consuming side, there is one subtlely in how the detail payloads are accessed. In the custom element case, the action callback is registered as a DOM EventListener. That is, when using the following form:
<oj-greet-with-actions onresponseDetected={this.handleEventResponse}/>
The callback acts as a true DOM event listener, and, as such, receives a single
event
argument of type
CustomEvent<Detail>
. However, when using the
VComponent class form:
<GreetWithActions onResponseDetected={this.handleActionResponse}/>
The callback will again receive a single argument, but of type
Detail
rather than
CustomEvent<Detail>
. The difference between
custom element usage and VComponent class usage is admittedly non-obvious.
Our recommendation for you is to use the VComponent class form when that is
available.
Manage State Properties
Components may track internal state that is not reflected through their properties. VComponent API provides a state mechanism to support this.
A VComponent API custom element can determine what content to render based exclusively on properties that are passed into the component by the parent component. This is useful for VComponent API components that are fully controlled by the parent component. However, some components may benefit from their own internal state properties that are not passed in, but rather exist locally in the component and render content based on state changes. VComponent authors can leverage Preact's local state mechanism for these cases.
Declare State
The process of declaring local state fields is very similar to the way that you define VComponent API properties. In both cases, start by declaring a type.
This example updates our component to display a goodbye message in response to the user clicking a Done button. The sample uses a boolean local state field done to track whether this state is reached.
type State = {
done: boolean;
// Other state fields go here
};
As with the properties type, we associate the state type with the VComponent implementation by leveraging generics. The VComponent API component class exposes two type parameters:
Props
: the first type parameter specifies the properties object typeState
: the second type parameter specifies the State object type
The new declaration with both type parameters looks like this:
export class GreetWithState extends Component<ExtendGlobalProps<Props>, State> {
}
Each VComponent API component has access to the local state through the
this.state
field. This field must be initialized at
construction time. Initialization can be done in one of two ways.
If your VComponent API component instance has a constructor, initialize local state there:
export class GreetWithState extends Component<ExtendGlobalProps<Props>, State> {
constructor() {
this.state = {
done: false
};
// Do other construction-time work here
}
Alternatively, TypeScript supports inline initialization of class fields. This slightly more compact form works well if you do not otherwise need a constructor:
export class GreetWithState extends Component<ExtendGlobalProps<Props>, State> {
state = {
done: false
};
// Component implementation goes here
}
Once local state has been declared and initialized, it can be referenced from within the render function to adjust how the virtual DOM content is rendered. For example, this sample shows our greeting component rendering a different message when the conversation is "done":
render(props: Props, state: State) {
// Derive greeting message off of the "done" state field
const greeting = state.done ?
'Goodbye' :
props.preferredGreeting;
return (
<div onClick={this._handleClick}>
<p>{greeting}, World!</p>
</div>
);
}
Update State
Updating local state has an important side effect: it triggers re-rendering of the component.
Note that this.state
is declared as a
Readonly
type. Other than the initial assignment to
this.state
in the constructor (or class field
initialization), neither this.state
nor fields on
this.state
should be mutated directly.
Instead, state updates are performed by calling the Preact
Component's setState
method. This method has two forms. The first form simply
takes an object representing the new state. For example, we can update our
done
state field by calling:
this.setState({ done: true });
This call queues the state update, which will trigger an (asynchronous) re-render of the component with the new state.
Although our sample only has a single state field, components can
have an arbitrary number of fields. The setState()
method accepts sparsely
populated objects; you are not required to provide values for all state fields. Any new values
that are provided will be merged on top of the current state.
The following version of our greeting component has been modified to use a numeric, enum-based counter to track how engaged the end user is. After three clicks, the greeting component ends the conversation.
oj-greet/with-state/with-state.tsx:
import { h, Component } from "preact";
import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
type Props = {
preferredGreeting?: string;
};
enum EngagementLevel {
Interested,
Bored,
Impatient,
Done,
}
type State = {
engagement: EngagementLevel;
};
/**
* @ojmetadata pack "oj-greet"
*/
@customElement("oj-greet-with-state")
export class GreetWithState extends Component<ExtendGlobalProps<Props>, State> {
state = {
engagement: EngagementLevel.Interested,
};
render() {
const greeting = this.getGreeting();
return (
<div onClick={this._handleClick}>
<p>{greeting}, World!</p>
</div>
);
}
private getGreeting() {
let greeting;
switch (this.state.engagement) {
case EngagementLevel.Bored:
greeting = "Okay";
break;
case EngagementLevel.Impatient:
greeting = "Whatever";
break;
case EngagementLevel.Done:
greeting = "Later";
break;
default:
greeting = this.props.preferredGreeting;
break;
}
return greeting;
}
private _handleClick = (event: MouseEvent) => {
this.setState((state: Readonly<State>) => {
// Once we have reached the Done state, we return null
// to indicate that no state update is needed.
return state.engagement === EngagementLevel.Done
? null
: { engagement: state.engagement + 1 };
});
};
static defaultProps: Partial<Props> = {
preferredGreeting: "Hello",
};
}
Understand the State Mechanism
One potential problem with the object-based form of
setState()
arises when the new value for a state field is
derived from the previous value. Given the asynchronous nature of this method,
simply inspecting this.state
may not be sufficient to determine
what the next value should be. If there is an outstanding call to
setState()
that has not yet been fully processed,
this.state
might not reflect the pending
update.
To better support cases where a state field's next value is dependent on the
previous value, setState()
supports a callback form. Rather
than passing in an object representing the new state, callers pass in a
function that takes two arguments: the current state and properties. This
callback function can inspect the state and props and return one of the
following values:
- A sparsely populated object representing any state updates to apply
or:
null
, if no state updates are required.
When multiple calls to setState()
are issued, the
state updates are chained. That is, the results of one call (whether object
or callback form) are fed into the subsequent callback. This ensures the
callback always sees the most up to date values, and that it can use this
information to correctly produce the next value.
Reference Child VComponents by Value
You can reference a VComponent child component from within JSX in two ways:
// Use intrinsic element name
function Parent() {
return <some-comp />;
}
// Use value-based element to reference
// the VComponent class or function value
function Parent() {
return <SomeComp />
}
We recommend that, whenever possible, you use the value-based element because:
- It is slightly more efficient as we are able to do more rendering in virtual DOM even before the VComponent's DOM element is created.
- It provides a more React/Preact-centric approach to use certain APIs, such as slots and listeners. (More on this below.)
- When working within a single project (that is, where both the VComponent and consuming code is in the same project), you have access to the VComponent class type information directly in your project source. You are not dependent on a build to produce a type definition for the class.
Elaborating on point 2, when you reference a VComponent through its intrinsic element name, you are limited to using this syntax for slots, as in the following example:
function Parent() {
<some-comp>
// This is a plain slot:
<img src="foo.png" slot="startIcon" />
// This is a template slot:
<template slot="itemTemplate render={ renderItem } />
</some-comp>
}
With a value-based element approach, you can achieve the same outcome more concisely, and in a form that is more familiar to app developers with a React/Preact background:
function Parent() {
<SomeComp startIcon={ <img src="foo.png" } itemTemplate={ renderItem }>
}
References to event listeners when using the value-based element form also aligns better with what a React/Preact developer would expect. For example, for intrinsic elements, we need to follow Preact's custom event naming conventions. So, for an event name of someCustomEvent, we end up with this listener prop:
function Parent() {
<some-comp onsomeCustomEvent={ handleSomeCustomEvent }>
}
When referencing the value-based element, this is:
function Parent() {
<SomeComp onSomeCustomEvent={ handleSomeCustomEvent }>
}
To conclude, use the value-based element whenever possible. For cases where you need to interact directly with the DOM element, use the intrinsic element form and then obtain a reference to it, as in this example:
function Parent() {
const someRef = useRef(null);
return <some-comp ref={ someRef } />;
}