A low profile component framework.


  • 1 file. 1 class. ~350 lines of code.
  • No build tools required.
  • Native web components.
  • Ideal for JAM stacks.
  • Identical on client & server.
  • Composition oriented.
  • Event delegation by default
  • Lots of examples.

Getting Started

Building a component with Tonic starts by creating a function or a class. The class should have at least one method named render which returns a template literal of HTML.

import Tonic from '@socketsupply/tonic'

class MyGreeting extends Tonic {
  render () {
    return this.html`<div>Hello, World.</div>`
  }
}

or

function MyGreeting () {
  return this.html`
    <div>Hello, World.</div>
  `
}

The HTML tag for your component will match the class or function name.

Note: Tonic is a thin wrapper around web components. Web components require a name with two or more parts. So your class name should be CamelCased (starting with an uppercase letter). For example, MyGreeting becomes <my-greeting></my-greeting>.


Next, register your component with Tonic.add(ClassName).

Tonic.add(MyGreeting)

After adding your Javascript to your HTML, you can use your component anywhere.

<html>
  <body>
    <my-greeting></my-greeting>

    <script src="index.js"></script>
  </body>
</html>

Note: Custom tags (in all browsers) require a closing tag (even if they have no children). Tonic doesn't add any "magic" to change how this works.


When the component is rendered by the browser, the result of your render function will be inserted into the component tag.

<html>
  <head>
    <script src="index.js"></script>
  </head>

  <body>
    <my-greeting>
      <div>Hello, World.</div>
    </my-greeting>
  </body>
</html>

A component (or its render function) may be an async or an async generator.

class GithubUrls extends Tonic {
  async * render () {
    yield this.html`<p>Loading...</p>`

    const res = await fetch('https://api.github.com/')
    const urls = await res.json()

    return this.html`
      <pre>
        ${JSON.stringify(urls, 2, 2)}
      </pre>
    `
  }
}

Properties

Props are properties that are passed to the component in the form of HTML attributes. For example...

class MyApp extends Tonic {
  render () {
    return this.html`
      <my-greeting message="Hello, World">
      </my-greeting>
    `
  }
}

Properties added to a component appear on this.props object.


Tonic has no templating language, it uses HTML! But since HTML only understands string values, we need some help to pass more complex values to a component, and for that we use this.html.

const foo = {
  hi: 'Hello, world',
  bye: 'Goodbye, and thanks for all the fish'
}

class MyApp extends Tonic {
  render () {
    return this.html`
      <my-greeting messages="${foo}">
      </my-greeting>
    `
  }
}
class MyGreeting extends Tonic {
  render () {
    return this.html`
      <h1>${this.props.messages.hi}</h1>
    `
  }
}

Note: A property named fooBar='30' will become lowercased (as per the HTML spec). If you want the property name to be camel cased when added to the props object, use foo-bar='30' to get this.props.fooBar.


You can use the "spread" operator to expand object literals into html properties.

class MyComponent extends Tonic {
  render () {
    const o = {
      a: 'testing',
      b: 2.2,
      fooBar: 'ok'
    }

    return this.html`
      <some-component ...${o}>
      </some-component>

      <div ...${o}>
      </div>
    `
  }
}

The above component renders the following output.

<my-component>
  <some-component a="testing" b="2.2" foo-bar="ok">
    <div a="testing" b="2.2" foo-bar="ok">
    </div>
  </some-component>

  <div a="testing" b="2.2" foo-bar="ok">
  </div>
</my-component>

Updating properties

There is no evidence that Virtual DOMs improve performance across a broad set of use cases, but it's certain that they greatly increase complexity. Tonic doesn't use them. Instead, we recommend incremental updates. Simply put, you re-render a component when you think the time is right. The rule of thumb is to only re-render what is absolutely needed.


To manually update a component you can use the .reRender() method. This method receives either an object or a function. For example...

// Update a component's properties
this.reRender(props => ({
  ...props,
  color: 'red'
}))

// Reset a component's properties
this.reRender({ color: 'red' })

// Re-render a component with its existing properties
this.reRender()

The .reRender() method can also be called directly on a component.

document.getElementById('parent').reRender({ data: [1,2,3, ...9999] })

Component State

this.state is a plain-old javascript object. Its value will be persisted if the component is re-rendered. Any element that has an id attribute can use state, and any component that uses state must have an id property.

//
// Update a component's state
//
this.state.color = 'red'

//
// Reset a component's state
//
this.state = { color: 'red' }
<my-app id="my-app"></my-app>
<!-- always set a unique ID if you have multiple elems. -->
<my-downloader id="download-chrome" app="chrome"></my-downloader>
<my-downloader id="download-firefox" app="firefox"></my-downloader>

Setting the state will not cause a component to re-render. This way you can make incremental updates. Components can be updated independently. And rendering only happens only when necessary.

Remember to clean up! States are just a set of key-value pairs on the Tonic object. So if you create temporary components that use state, clean up their state after you delete them. For example, if a list of a component with thousands of temporary child elements all uses state, I should delete their state after they get destroyed, delete Tonic._states[someRandomId].

Composition

Nesting

With Tonic you can nest templates from other functions or methods.

class MyPage {
  renderHeader () {
    return this.html`<h1>Header</h1>`
  }
  render () {
    return this.html`
      ${this.renderHeader()}
      <main>My page</main>
    `
  }
}

This means you can break up your render() {} method into multiple methods or re-usable functions.

Conditionals

If you want to do conditional rendering you can use if statements.

const LoginPage {
  render () {
    let message = 'Please Log in'
    if (this.props.user) {
      message = this.html`<div>Welcome ${this.props.user.name}</div>`
    }

    return this.html`<div class="message">${message}</div>`
  }
}

Children

Once you add components, they can be nested any way you want. The property this.children will get this component's child elements so that you can read, mutate or wrap them.

class ParentComponent extends Tonic {
  render () {
    return this.html`
      <div class="parent">
        <another-component>
          ${this.children}
        </another-component>
      </div>
    `
  }
}

Tonic.add(ParentComponent)

class ChildComponent extends Tonic {
  render () {
    return this.html`
      <div class="child">
        ${this.props.value}
      </div>
    `
  }
}

Tonic.add(ChildComponent)

Input HTML

<parent-component>
  <child-component value="hello world"></child-component>
</parent-component>

Output HTML

<parent-component>
  <div class="parent">
    <another-component>
      <child-component>
        <div class="child">hello world</div>
      </child-component>
    </another-component>
  </div>
</parent-component>

Repeating templates

You can embed an array of template results using this.html

class TodoList extends Tonic {
  render () {
    const todos = this.state.todos

    const lis = []
    for (const todo of todos) {
      lis.push(this.html`<li>${todo.value}</li>`)
    }

    return this.html`<ul>${lis}</ul>`
  }
}

By using an array of template results, tonic will render your repeating templates for you.

Events

There are two kinds of events. Lifecycle Events and Interaction Events. Tonic uses the regular web component lifecycle events but improves on them, see the API section for more details.

Tonic helps you capture interaction events without turning your html into property spaghetti. It also helps you organize and optimize it.

class Example extends Tonic {
  //
  // You can listen to any DOM event that happens in your component
  // by creating a method with the corresponding name. The method will
  // receive the plain old Javascript event object.
  //
  mouseover (e) {
    // ...
  }

  change (e) {
    // ...
  }

  willConnect () {
    // The component will connect.
  }

  connected () {
    // The component has rendered.
  }

  disconnected () {
    // The component has disconnected.
  }

  updated () {
    // The component has re-rendered.
  }

  click (e) {
    //
    // You may want to check which element in the component was actually
    // clicked. You can also check the `e.path` attribute to see what was
    // clicked (helpful when handling clicks on top of SVGs).
    //
    if (!e.target.matches('.parent')) return

    // ...
  }

  render () {
    return this.html`<div></div>`
  }
}

The convention of most frameworks is to attach individual event listeners, such as onClick={myHandler()} or click=myHandler. In the case where you have a table with 2000 rows, this would create 2000 individual listeners.

Tonic prefers the event delegation pattern. With event delegation, we attach a single event listener and watch for interactions on the child elements of a component. With this approach, fewer listeners are created and we do not need to rebind them when the DOM is re-created.

Each event handler method will receive the plain old Javascript event object. This object contains a target property, the exact element that was clicked. The path property is an array of elements containing the exact hierarchy.

Some helpful native DOM APIs for testing the properties of an element:

Tonic also provides a helper function that checks if the element matches the selector, and if not, tries to find the closest match.

Tonic.match(el, 'selector')

You can attach an event handler in any component, for example here we attach an event handler in a ParentElement component that handles clicks from DOM elements in ChildElement.

Example

class ChildElement extends Tonic {
  render () {
    return this.html`
      <span data-event="click-me" data-bar="true">Click Me</span>
    `
  }
}

class ParentElement extends Tonic {
  click (e) {
    const el = Tonic.match(e.target, '[data-event]')

    if (el.dataset.event === 'click-me') {
      console.log(el.dataset.bar)
    }
  }

  render () {
    return this.html`
      <child-element>
      </child-element>
    `
  }
}

The event object has an Event.stopPropagation() method that is useful for preventing an event from bubbling up to parent components. You may also be interested in the Event.preventDefault() method.

Methods

A method is a function of a component. It can help to organize the internal logic of a component.

The constructor is a special method that is called once each time an instance of your component is created.

class MyComponent extends Tonic {
  constructor () {
    super()
    // ...
  }

  myMethod (n) {
    this.state.number = n
    this.reRender()
  }

  render () {
    const n = this.state.number

    return this.html`
      <div>
        The number is ${n}
      </div>
    `
  }
}

After the component is created, the method myMethod can be called.

document.getElementById('foo').myMethod(42)

Styling

Tonic supports multiple approaches to safely styling components.

Option 1. Inline styles

Inline styles are a security risk. Tonic provides the styles() method so you can inline styles safely. Tonic will apply the style properties when the render() method is called.

class MyGreeting extends Tonic {
  styles () {
    return {
      a: {
        color: this.props.fg,
        fontSize: '30px'
      },
      b: {
        backgroundColor: this.props.bg,
        padding: '10px'
      }
    }
  }

  render () {
    return this.html`<div styles="a b">${this.children}</div>`
  }
}
<my-greeting fg="white" bg="red">Hello, World</my-greeting>

Option 2. Dynamic Stylesheets

The stylesheet() method will add a stylesheet to your component.

class MyGreeting extends Tonic {
  stylesheet () {
    return `
      my-greeting div {
        display: ${this.props.display};
      }
    `
  }

  render () {
    return this.html`<div></div>`
  }
}

Option 3. Static Stylesheets

The static stylesheet() method will add a stylesheet to the document, but only once.

class MyGreeting extends Tonic {
  static stylesheet () {
    return `
      my-greeting div {
        border: 1px dotted #666;
      }
    `
  }

  render () {
    return this.html`<div></div>`
  }
}

Server Side Rendering.

Tonic components are exactly the same on the server. You don't need any build tools or any special treatment. Just use the tonic-ssr module (it mocks-up a few dom apis that tonic needs).

Check out the code for this site for a real life example.

CSP

Tonic is Content Security Policy friendly. This is a good introduction to CSPs if you're not already familiar with how they work. This is an example policy, it's quite liberal, in a real app you would want these rules to be more specific.

<meta
  http-equiv="Content-Security-Policy"
  content="
    default-src 'self';
    font-src 'self' https:;
    img-src 'self' https: data:;
    style-src 'self' 'nonce-123' https:;
    script-src 'self' 'nonce-123';
    connect-src 'self' https:;">

For Tonic to work with a CSP, you need to set the nonce property. For example, given the above policy you would add the following to your javascript...

Tonic.nonce = 'c213ef6'