DEV Community

Cover image for Introducing Joist
Danny Blue
Danny Blue

Posted on • Updated on

Introducing Joist

Alt Text

I've done it. I have done the thing that everyone tells you not to do as a developer directly following "don't build your own cms (which I have also done)". I built my own framework, Joist.

Over the past 2 or so years I have been been thinking about how I personally like to write applications and build components and couldn't find anything that did EXACTLY what I wanted EXACTLY the way I wanted. So i built Joist, a framework that I want to use that I don't mind if you want to use too :).

Some of the things I wanted:

  • dependency injection
  • SMALL
  • opinionated state management
  • framework agnostic components (WebComponents)
  • view layer agnostic (You should be able to swap between no view library, lit-html and lighterhtml whenever you like.)

In my opinion Joist meets all of my criteria. It is opinionated in some aspects and flexible in others. On it's own @joist/component and @joist/di together weigh in at ~2kb gzipped and ~5kb with lit-html.

Getting Started

The easiest way to get started with Joist is by going to webcomponents.dev and just the Joist starter. Webcomponents.dev is a an EXCELLENT site that lets you build and publish components with a variety of libraries. (Seriously even if you don't care about Joist you should check it out.)

If you want to build an application you can use Create Snowpack App (CSP).

npx create-snowpack-app my-app --template @joist/starter-snowpack
Enter fullscreen mode Exit fullscreen mode

This will set you up with a dev server, production builds via rollup and unit testing via web-test-runner.

Elements

Joist is view library agnostic but comes with built in support for lit-html and is what we will use for all of our examples. Now let's see what a Joist element looks like.

import { component, JoistElement } from '@joist/component';
import { template, html } from '@joist/component/lit-html';

@component({
  tagName: 'my-element',
  state: {
    title: 'Hello World'
  },
  render: template(({ state }) => {
    return html`<h1>${state.title}</h1>`
  })
})
class MyElement extends JoistElement {}
Enter fullscreen mode Exit fullscreen mode

A Joist component is defined by extending the JoistElement base custom element and adding some component metadata. Metadata includes the tag name of the new element, the default state of the element and the render function. A joist render function is passed an object called RenderCtx.

Styling

When you are using shadow dom you can apply styles with the component styles property.

import { component, JoistElement } from '@joist/component';
import { template, html } from '@joist/component/lit-html';

@component({
  tagName: 'app-root',
  shadowDom: 'open',
  state: {
    title: 'Hello World'
  },
  styles: [`
    :host {
      display: block;
    }

    h1 {
      color: red;
    }
  `],
  render: template(({ state }) => {
    return html`
      <h1>${state.title}</h1>
    `
  })
})
class AppElement extends JoistElement {}
Enter fullscreen mode Exit fullscreen mode

Dependency Injection (DI)

At the heart of Joist is the dependency injector. The dependency injector itself is completely separate from components and is in its own package. Each Joist component has its own injector that inherits from a single global injector. This allows Joist components to construct their own locally scoped services as well as share global singletons. Services decorated with the "service" decorator will be treated as singletons.

Services can be injected into the constructor of other services via the "inject" decorator.

Custom elements can inject services with the get decorator. This maps a service to a property on any class that implements the InjectorBase interface. You can even use it with other web component libraries like Microsoft's FASTElement.

import { component, JoistElement, get } from '@joist/component';
import { service, inject } from '@joist/di';

@service()
class FooService {
  sayHello() {
    return 'Hello World';
  }
}

@service()
class BarService {
  constructor(@inject(FooService) private foo: FooService) {}

  sayHello() {
    return this.foo.sayHello();
  }
}

@component({
  tagName: 'app-root',
})
class AppElement extends JoistElement {
  @get(BarService)
  private myService!: BarService;

  connectedCallback() {
    super.connectedCallback();

    console.log(this.myservice.sayHello());
  }
}
Enter fullscreen mode Exit fullscreen mode

Property based DI with the get decorator is "lazy", meaning that the service won't be instantiated until the first time it is requested.

State

Joist components differentiate between element properties and internal state. Updating internal state will cause the component view to update. This is on purpose in order to make state updates explicit. Any change in state will result in a change in the view. Joist's component state is accessible via the State service. You can update state with the setValue and patchValue methods and watch for state changes with onChange.

import { component, State, JoistElement, get } from '@joist/component';
import { template, html } from '@joist/component/lit-html';

@component<number>({
  tagName: 'my-counter',
  state: 0,
  render: template(({ state }) => html`${state}`)
})
class MyCounterElement extends JoistElement {
  @get(State)
  private state!: State<number>;

  connectedCallback() {
    super.connectedCallback();

    setInterval(() => this.update(), 1000);
  }

  private update() {
    const { value } = this.state;

    this.state.setValue(value + 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Component state is updated asynchronously which means you can pass setValue and patchValue a promise that resolves to your new state.

import { component, State, JoistElement, get } from '@joist/component';
import { template, html } from '@joist/component/lit-html';

@component<number>({
  tagName: 'my-counter',
  state: 'Hello',
  render: template(({ state }) => html`${state}`)
})
class MyCounterElement extends JoistElement {
  @get(State)
  private state!: State<number>;

  connectedCallback() {
    super.connectedCallback();

    const res = Promise.resolve('World');

    this.state.setValue(res);
  }
}
Enter fullscreen mode Exit fullscreen mode

Properties

Since Joist elements are Custom Elements, properties behave as you would expect for an HTMLElement. Decorating your properties with the "property" decorator which will cause your elements onPropChanges method to be called with a list of PropChangs whenever that property is updated.

import { 
  component, 
  State, 
  JoistElement, 
  property, 
  get, 
  PropChange 
} from '@joist/component';

@component({
  tagName: 'app-root',
  state: ''
})
class AppElement extends JoistElement {
  @get(State)
  private state!: State<string>;

  @property()
  public greeting = '';

  onPropChanges(_changes: PropChange[]) {
    this.state.setValue(this.greeting);
  }
}
Enter fullscreen mode Exit fullscreen mode

Properties also have a hook for runtime validation. The property decorator can accept 1 or many validation functions that will be run when that property is set. This is particularly helpful if you are distributing components. A validator function either return null, meaning there is no error, or an error message.

import { component, JoistElement, property } from '@joist/component';

function isString(val: unknown) {
  if (typeof val === 'string') {
    return null;
  }

  return { message: 'error' };
}

function isLongerThan(length: number) {
  return function (val: string) {
    if (val.length > length) {
      return null;
    }

    return { message: 'Incorrect length' };
  }
}

@component()
class MyElement extends JoistElement {
  @property(isString, isLongerThan(2))
  public hello = 'Hello World';
}
Enter fullscreen mode Exit fullscreen mode

Handlers

Handlers are one of the more unique features of Joist. Handlers are way to map an "action" to corresponding methods. Multiple methods can be mapped to a single action. Multiple actions can be mapped to a single method. Handlers can can also match action based on a regular expression. The general flow is event -> handler -> state change.

import { 
  component, 
  State, 
  handle, 
  JoistElement, 
  get 
} from '@joist/component';
import { template, html } from '@joist/component/lit-html';

@component<number>({
  tagName: 'app-root',
  state: 0,
  render: template(({ state, run }) => {
    return html`
      <button @click=${run('dec')}>-</button>
      <span>${state}</span>
      <button @click=${run('inc')}>+</button>
    `
  })
})
class AppElement extends JoistElement {
  @get(State)
  private state!: State<number>;

  @handle('inc') increment() {
    this.state.setValue(this.state.value + 1);
  }

  @handle('dec') decrement() {
    this.state.setValue(this.state.value - 1);
  }

  @handle('inc')
  @handle('dec')
  either() {
    console.log('CALLED WHEN EITHER IS RUN')
  }

  @handle(/.*/) all(e: Event, payload: any, name: string) {
    console.log('CALLED WHEN REGEX MATCHES');
    console.log('TRIGGERING EVENT', e);
    console.log('payload', payload);
    console.log('matched name', name);
  }
}
Enter fullscreen mode Exit fullscreen mode

Closing Thoughts

That is a quick and dirty overview of Joist. Joist is built to be opinionated but can be used à la carte. The package that I didn't cover here is @joist/router which is stable but still a work in progress. Joist is a project I have been playing around and thinking about for quite a while and I think I am pretty happy with the result! Give it a shot, let me know what you think.

Top comments (2)

Collapse
 
kosich profile image
Kostia Palchyk

Big and good work, Danny! Congrats! 👍

Collapse
 
deebloo profile image
Danny Blue

Thanks! It always feel good to "finish" something