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

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:

  1. As direct children, with no slot attribute. This is also known as the default slot.
  2. 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.

Here is a new version of the greeting component that delegates rendering to an optional 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 to dispatchEvent.
  • 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 type
  • State: 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:

  1. 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.
  2. It provides a more React/Preact-centric approach to use certain APIs, such as slots and listeners. (More on this below.)
  3. 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 } />;
}