vanilla js web components


You don't need a fancy framework to use components in your web projects. For simple projects vanilla components are often the best.

The Pitch

Organising your code into discrete logic centers has compelling benefits such that it's a design pattern you see everywhere. React was my first experience with front end components, but it comes with a bunch of features which may not be appropriate for simple projects.

A vanilla component is just component functionality written within the project's codebase, and it's surprisingly easy to achieve. However, as with all things node there's a plethora of tools you can use to improve functionality and minimalise your code base, so in truth is more like "vanilla with sprinkles", but whatever.

The functionality I've tried to achieve with my components breaks down something like this:

  • ES6 class instantiation and extension
  • a single self contained folder for each component
  • minimal boilerplate for importing / instantiating
  • expose a single element ready for placement in the DOM
  • auto bind class methods to browser events
  • state awareness (in an abstract way, not a react way)

As in any minimalist design, it's really defined by what it doesn't do. Most notably, it doesn't attempt to determine whether it's element should be re-rendered. This is what keeps things really simple with no dogma about how state should be managed, and performant because elements are rendered when, and only when, you tell them to.

The Component

There's not much to the component itself, the code below is what I'm using presently.

/ eslint-env browser /
/ global $ /
import classNames from 'classnames'

const renderUtils = {
  classNames
}

export default class Component {
  constructor (initialState, layout, styles, storeKey) {
    Object.assign(this, { layout, styles, storeKey })
    this.hydrate(initialState)
    this.el = false
  }
  render () {
    const component = this
    const locals = Object.assign(
      {},
      renderUtils,
      this.styles,
      this._state
    )
    const el = $(this.layout(locals))
    const events = [ 'click', 'input', 'submit' ]
    events.forEach((e) => {
      // convert some-event to SomeEvent
      const humps = e.replace(/(^\w|-\w)/g, (m) => m.slice(-1).toUpperCase())
      $([data-on-${e}], el).each(function () {
        const methodName = $(this).data(on${humps})
        $(this).on(e, component[methodName].bind(component))
      })
    })
    // store a reference to the component on the element
    el.data('component', this)
    // attach to dom
    if (this.el) this.el.replaceWith(el)
    // store element
    this.el = el
    return this
  }
  hydrate (initialState) {
    const { storeKey } = this
    let state
    if (!storeKey) {
      this._state = initialState
    } else {
      state = window.localStorage.getItem(storeKey)
      if (state) this._state = JSON.parse(state)
      else this._state = initialState
    }
    return this._state
  }
  state (update) {
    if (!update) return this._state
    this._state = Object.assign(this._state, update)
    const store = JSON.stringify(this._state)
    if (this.storeKey) window.localStorage.setItem(this.storeKey, store)
    return this._state
  }
}

$.fn.extend({
  component: function () {
    console.log('component')
    const component = $(this[0]).data('component')
    if (!component) throw new Error('el doesnt have attached component')
    else return component
  }
})

The render fn attaches events to the rendered element. For example if you define an element with the attribute data-on-click="clickMyButton", then an onClick event will be created which calls method clickMyButton on your component. It's not that amazing but this cuts down on loads of boilerplate.

The hydrate and state functions are where I've been playing around with stateful components that store their state in browser storage to survive page loads. I'm still undecided as to whether this is a good idea or not, but it's working ok for the time being.

webpack

Most of what makes this pattern feel like a component happens in webpack. Essentially, I'm using a pug and a less loader to allow you to import layout and styles into the component in js.

A simple component implementation might look like this:

import styles from './styles'
import layout from './layout'
import Component from '../Component'
import Menu from '../Menu'

export default class MyComponent extends Component {
  constructor (initialState = {}) {
    super(initialState, layout, styles, 'myComponent')
  }
}

where ./styles is styles.less and ./layout is layout.pug.

You can see the webpack config I'm using in dotdotdown