Slaying a UI Antipattern with Web Components (and TypeScript)

<node-package> is a web component that lets you easily embed details about an npm package on your site. To use it you add a tag to your HTML and get a nice bit of UI rendered.

<node-package name="twitter-status"></node-package>

Screenshot of the node-package component

It's a component I built with the Nutmeg web component library for use in projects like the Angular PWA Guide. On the surface it's a fairly simple component: fetch some data and render it. When you start digging under the covers there are some hidden complexities and we'll look at a refactor (#53, #61) implementing the RemoteData pattern.

The problem

With web components, properties/attributes can be set or changed at anytime. This makes component architecture more difficult and leaves you with no guarantee that attributes name, in the case of <node-package>, will be set when the constructor or connectedCallback runs. To cover all of these scenarios you end up writing complex logic to handle required values never getting set or changing to completely different values without warning.

Value never set

<node-package></node-package>

Value set as an attribute

<node-package name="twitter-status"></node-package>

Value set after rendering

<node-package></node-package>
<script>
  document.querySelector('node-package').name = 'twitter-status';
</script>

Value changed after rendering

<node-package name="github-repository"></node-package>
<script>
  document.querySelector('node-package').name = 'twitter-status';
</script>

One option for handling this complexity is lit-html's until method, which works pretty well. It takes a promise and until that resolves it renders some default content. In this case it displays the loadingTemplate until fetchPackage resolves then renders contentTemplate. If an error happens it renders errorTemplate instead.

public get template(): TemplateResult {
  return html`
    <div id="content">
      ${this.headerTemplate}
      ${until(this.fetchPackage()
                .then(() => this.contentTemplate)
                .catch((error: string) => this.errorTemplate(error)),
              this.loadingTemplate)}
    </div>
  `;
}

This has a couple of downsides.

  1. fetchPackage has to encapsulate all of the logic about when it should fetch the data and when the data should be updated.
  2. contentTemplate and all sub templates have to guard against pkg (the data from the remote API) being null
  3. There is no language enforcement to make sure developers handle errors.

For #1 this logic gets a bit messy.

if not name
  do nothing
else if name and not package
  fetch package
else if name and package and name not equal to package name
  fetch package
else
  render package

For #2 we end up with a lot of sub templates like this:

  if (!this.pkg) { return html`` }

Or maybe worse you just ! (non-null assertion operator) the property to make TypeScript think it's getting set magically. It sort of is but you shouldn't do this. It's very easy to try to use the pkg value when it's not going to be available.

private pkg!: Pkg;

For #3 developers tend to be lazy and think only about the happy path. Having the language enforce error handling forces developers to produce much more robust code.

RemoteData states

Let's take a stab at refactoring this using the RemoteData pattern and a micro library I wrote.

The main idea of RemoteData is that your component can be in one of four states.

  • Initialized - No request had been made to get the remote data.
  • Pending - There is currently a request to get the remote data.
  • Success - Request completed and we got the data we wanted.
  • Failure - Request didn't complete or didn't get the data we wanted.

Note that some implementations use different terms like NotAsked and Loading or add additional states like Refreshing. Use the implementation that fits you.

Collectively we will call these four states as RemoteData and we can build our logic to make sure we handle each of them.

Under the covers these states are basically simple classes and a single type. You might notice these weird D and E bits. These are TypeScript generics and they let you as a developer cast specific types into generic code you are using. We'll cover how to use them in the next section.

export type RemoteData<E, D> = Initialized | Pending | Failure<E> | Success<D>;

export enum Kinds {
  Initialized = 'Initialized',
  Pending = 'Pending',
  Failure = 'Failure',
  Success = 'Success',
}

export class Initialized {
  readonly kind = Kinds.Initialized;
}

export class Pending {
  readonly kind = Kinds.Pending;
}

export class Success<D> {
  readonly kind = Kinds.Failure;

  constructor(public data: D) {}
}

export class Failure<E> {
  readonly kind = Kinds.Success;

  constructor(public error: E) {}
}

Note: TypeScript treats empty classes as any so we include the kind property and switch type guards have limited success.

Install RemoteData

The micro library can be installed with npm.

npm install @abraham/remotedata

Then import and define the State type.

import { Failure, Initialized, Pending, RemoteData, Success } from '@abraham/remotedata';

interface SuccessData {
  pkg: Pkg,
  selectedTab: InstallSource,
}
type State = RemoteData<string, SuccessData>;

Here we are defining a type State that is a specific implementation of RemoteData. This implementation says that the data parameter of Success will be of type SuccessData and the error parameter of Failure will be a string. State isn't strictly needed but makes it easier than putting RemoteData<string, SuccessData> everywhere.

The Success type should only be data and should not include a view instance. This is the data that you would pass to a view for rendering.

Note: the failure type goes first because success is the "right" (as in correct) result and goes in the right (as in direction) position.

Now TypeScript will prevent you from setting invalid state types.

// Typescript won't let you do this because we defined Failure as taking a string.
let state: State = new Failure(404);

Initialized

Now set the default state.

export class NodePackage extends Seed {
  private state: State = new Initialized();
  ...
}

Update template to handle the Initialized state. We know that no request had been made for the data yet. If name is present we can request the data with fetchPackage.

public get template(): TemplateResult {
  let content = new PendingView().content;
  if (this.state instanceof Initialized) {
    if (this.name) { this.fetchPackage(); }
  }

  return html`
    <div id="content">
      ${this.header}
      ${this.content}
    </div>
  `;
}

Note: If you don't like using instanceof you can use this.state.kind === 'Initialized' instead.

fetchPackage will set the state to Pending and start a fetch request. When that resolves it will set the state to Success or Failure. Since we are changing the state here, make sure to render the component again.

private async fetchPackage(): Promise<void> {
  if (this.name) {
    this.state = new Pending();
    try {
      const pkg = new Pkg(await this.api.fetch(this.name));
      this.state = new Success({ selectedTab: DEFAULT_INSTALL_SOURCE, pkg });
    } catch (error) {
      this.state = new Failure(error);
    }
    this.render();
  }
}

Pending

We now have Pending in the mix so let's update template to explicitly handle it even though it's mostly handled with the default this.loading template rendering.

public get template(): TemplateResult {
  let content = new PendingView().content;
  if (this.state instanceof Initialized) {
    if (this.name) { this.fetchPackage(); }
  } else if (this.state instanceof Pending) {
    // Don't actually do anything because we `this.loading` was already the default.
  }

  return html`
    <div id="content">
      ${this.header}
      ${this.content}
    </div>
  `;
}

Success

Once the fetchPackage fetch resolves and we have the required data, we will transition to a Success state. Earlier we defined the parameter type that gets passed to Success as SuccessData (That could just as easily have been a string, Pkg by itself, or any other type) so TypeScript won't let us pass anything else.

this.state = new Success({ selectedTab: DEFAULT_INSTALL_SOURCE, pkg });

Again we need to update template, this time to handle the Success state.

public get template(): TemplateResult {
  let content = new PendingView().content;
  if (this.state instanceof Initialized) {
    if (this.name) { this.fetchPackage(); }
  } else if (this.state instanceof Pending) {
    // Don't actually do anything because we `this.loading` was already defaulted to.
  }  else if (this.state instanceof Success) {
    if (this.updateData) { this.fetchPackage(); }
    content = new SuccessView(this, this.state.pkg, this.state.selectedTab).content;
  }

  return html`
    <div id="content">
      ${this.header}
      ${this.content}
    </div>
  `;
}

The key aspect here is that any code called within the this.state instanceof Success block can be certain the required data is available. We can then have a simpler rendering and checks to see if name has changed.

Failure

The final state is Failure! You'll notice that we set this.loading as the default value of content in template. If code runs as it is and fetchPackage sets the state to Failure, template will incorrectly have the UI continue to render as if it's loading. We'll continue the if/else block here and look into a better approach next.

public get template(): TemplateResult {
  let content = new PendingView().content;
  if (this.state instanceof Initialized) {
    if (this.name) { this.fetchPackage(); }
  } else if (this.state instanceof Pending) {
    // Don't actually do anything because we `this.loading` was already defaulted to.
  }  else if (this.state instanceof Success) {
    if (this.updateData) { this.fetchPackage(); }
    content = new SuccessView(this, this.state.pkg, this.state.selectedTab).content;
  } else if (this.state instanceof Failure) {
    if (this.updateData) { this.fetchPackage(); }
    content = new ErrorView(this.state.error).content;
  }

  return html`
    <div id="content">
      ${this.header}
      ${this.content}
    </div>
  `;
}

With all the State types covered we can be confident to always render the correct UI.

RemoteData fold

Let's take this to the next level and ensure that the error state is always taken care of. We'll do that with the introduction of a new method from RemoteData: fold. This is a little more complex and for people less familiar with TypeScript it'll look like syntax soup.

Basically, fold is a method that takes four methods and returns an uncalled method. The four methods that get passed in correspond with the four types of State that need to be handled. The returned uncalled method takes a single value that must be a State type. TypeScript will ensure that each of the four methods passed to fold accept and return the correct types. When called, the method returned from fold will identify which class of State is passed in and call the correct method from the four handlers. For example, if you call the returned method with Pending, the second method passed to fold will be called.

TypeScript generics are used again for fold<T, E, D>. Were T is the type each handler will return, D is the type the Success handler will accept, and E is the type the Failure handler will accept.

It works like this:

const view = fold(
  () => handleInitialized(),
  () => handlePending(),
  (data) => handleSuccess(data),
  (error) => handleFailure(error)
)

view(state);

Since fold requires taking a method for each State, developers will have to handle Initialized, Pending, Success, AND Failure.

Let's take a look at updating template to use fold with the assumption that we've pulled the state handling logic into discrete methods.

private get view(): (state: State) => TemplateResult {
  return fold<TemplateResult, string, SuccessData>(
    () => this.handleInitialized(),
    () => this.handlePending(),
    (error: string) => this.handleFailure(error),
    (data: SuccessData) => this.handleSuccess(data),
  );
}

public get template(): TemplateResult {
  return html`
    <div id="content">
      ${this.header}
      ${this.view(this.state)}
    </div>
  `;
}

Give the RemoteData library a try! You can find an example of its use in the <node-package> component. Or learn more at the "const name: Language = 'TypeScript';" session at Midwest JS August 9th with me and Pearl.

fold on and prosper!

Prior art