/dompteuse

Fast Virtual DOM with Reactive updating.

Primary LanguageJavaScriptMIT LicenseMIT

dompteuse

© DC Comics

Fast Virtual DOM with Reactive updating.

  • Fast thanks to snabbdom, most, component isolation and async RAF rendering
  • Global and local states use streams for greater composition
  • No JS class / this nonsense
  • Tiny size in KB
  • Comes with useful logs
  • Very typesafe / typescript friendly

Content

Componentization

dompteuse adds the concept of encapsulated components to pure functional virtual dom.
Standard Virtual nodes and components are composed to build a Vnode tree that can scale in size and complexity.

A component is simply a function that takes an option object as an argument and returns a Vnode ready to be used inside its parent children.

Note: typescript will be used in the examples, javascript devs can simply ignore the types annotations.

import { Component } from 'dompteuse'

export default function(props?: Props) {
  return Component({
    key: 'Select',
    props,
    defaultProps,
    initState,
    connect,
    render
  })
}

Let's look at the option object properties:

key

Mandatory String
This is the standard Virtual node key used to uniquely identify this Vnode. It is also used for logging purposes, so it is usually just the name of the component.

props

Optional Object
An object representing all the properties passed by our parent. Typically props either represent state that is maintained outside the component or properties used to tweak the component behavior.
The render function will be called if the props object changed shallowly, hence it's a better practice to use a flat object. Note: props and state are separated exactly like in React as it works great. The same best practices apply.

defaultProps

Optional Object (upper type of props)
An object with some keys of the props that should be used if the parent do not specify all the props.

initState

Mandatory Object
A function taking the initial props as an argument and returning the starting state.

connect

Mandatory function(on: StreamSub<State>, events: Events): void
Connects the component to the rest of the app and computes the local state of the component.
connect is called only once when the component is mounted.

connect is called with two arguments:
on registers a Stream that modifies the component local state.
events is the interface used to listen to bubbling dom events or emit custom Messages.

connect arguably has more interesting characteristics than the imperative approach React use (i.e setState):

  • Streams are composable, callbacks are not. Doing things like throttling or only listening to the very last ajax action fired is a recurrent, non abstractable pain with imperative code.

  • Separation of the markup and the component hierarchy wiring logic.
    render functions in react can get pretty messy, having to pass callbacks several level down. What's more, callback references often change over time (most likely from using partial application) and we can no longer apply streamlined performance optimizations because some props truly represent data while other props are callbacks that may or may not purposedly change.
    Designers may also feel more at ease with working with a clean tree of Vnodes without having to think about the app logic.

Example:

import { StreamSub, Events, Message } from 'dompteuse'

// A custom, type-safe message used to communicate with our parent hierarchy
export const Opened = Message('opened')

function connect(on: StreamSub<State>, events: Events) {
  // Subscribe to the stream of button clicks and update our state every time it changes
  on(events.listen('button', 'click'), state => {
    const opened = !state.opened

    // dispatch a custom message conditionally.
    // Any parent component can listen to it using  events.listen('css selector', Opened)
    if (opened) dom.emit(Opened())

    // Any 'on' handler must return the new component state
    return merge(state, { opened })
  })
}

connect can listen to any kind of most stream, not just the provided dom event stream. See global streams.
Just like with props, a redraw will only get scheduled if the state object changed shallowly so returning the current state in on() will skip rerendering.

render

Mandatory function(props: Props, state: State): VNode

Returns the Vnode tree based on the props and state.

Example:

import { h } from 'dompteuse'

interface State {
  text: string
}

function render(props: void, state: State) {
  const { text } = state

  return h('div#text', [
    h('h1', 'Hello'),
    h('p', text)
  ])
}

Global streams

A construct is provided to easily build push-based global streams in a typesafe fashion. This is entirely optional.

You typically want to keep very transient state as local as possible so that it remains encapsulated in a component and do not leak up such as:

  • Whether a select dropdown is opened
  • The component has focus
  • Which grid row is highlighted
  • Basically any state that resets if the user navigate away then come back

Additionally, keeping state that is only useful to one screen should be kept inside the top-most component of that screen and no higher.

That leaves global state, which can be updated from anywhere and is read from multiple screens such as:

  • The current route
  • User preferences
  • Any raw domain data that will be mapped/filtered/transformed in the different screens.

The subscription to this global stream is automatically released when the component is unmounted.

Example:

import { Message, GlobalStream } from 'dompteuse'
import merge from './util/obj/merge'


export const setUserName = Message<string>('setUserName')

interface UserState {
  name: string
}

const initialState = { name: 'bob' }

// This exports a stream ready to be used in a component's connect function
export default GlobalStream<UserState>(initialState, on => {
  on(setUserName, (state, name) =>
    merge(state, { name })
  )
})

// ...
// Subscribe to it in a component's connect
import userState from './userState'

// Provide an initial value
function initialState() {
  return {
    userName: userState.value.name
  }
}

connect(on: StreamSub<State>, events: Events) {
  on(userState, (state, user) => {
    // 'Copy' the global user name into our local component state to make it available to `render`
    return merge(state, { userName: user.name })
  })
}

// ...
// Then anywhere else, import setUserName and use it
setUserName('Monique')

Api

h

Creates a Vnode
This is proxied to snabbdom's h so we can add our type definitions transparently.

import { h } from 'dompteuse'
h('div', 'hello')

startApp

Performs the initial render of the app synchronously.

function startApp<S>(options: {
  app: Vnode // The root Vnode
  elm: HTMLElement // The root element where the app will be rendered
  patch: PatchFunction // The snabbdom patch function to be used during renders
}): void;
import { snabbdom, startApp } from 'dompteuse'
import app from './app'

declare var require: any
const patch = snabbdom.init([
  require('snabbdom/modules/class'),
  require('snabbdom/modules/props'),
  require('snabbdom/modules/attributes'),
  require('snabbdom/modules/style')
])

startApp({ app, patch, elm: document.body })

Message

Create a custom application message used to either communicate between components or push to a GlobalStream.

import { Message } from 'dompteuse'

// Message taking no arguments
const increment = Message('increment')

// Message taking one argument
const incrementBy = Message<number>('incrementBy')

StreamSub

The registration function passed to connect used to subscribe to a stream and update the component state.

Signature:

on(stream: Stream<A>, handler: (state: State, payload: A) => State)

Events

The api object passed to connect and used to communicate via events propagating through the DOM.

Detail:

events.listen(selector: string, eventName: string): Stream<Event>
events.listen<P>(selector: string, message: Message<P>): Stream<P>
events.emit<P>(message: MessagePayload<P>): void

Example:

import { StreamSub, Events, Message } from 'dompteuse'
import { Opened } from './someComponent'

const Increment = Message('increment')

connect(on: StreamSub<State>, events: Events) {

  // Regular DOM events
  const clickStream = events.listen('button', 'click')

  // Custom Messages
  const openStream = events.listen('.someComponent', Opened)

  // Emit a bubbling custom message. Any parent component can listen to it.
  events.emit(Increment())
}