Skip to content

Fast Virtual DOM components with Reactive updating

License

Notifications You must be signed in to change notification settings

AlexGalays/kaiju

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Kaiju

logo

kaiju is a view layer used to build an efficient tree of stateless/stateful components and help you manage that tree data.

  • Data management (local/global/inter-component/intra-component) is unified via stores (FSM)
  • Fast, thanks to snabbdom, aggressive component rendering isolation (a key stroke in one input component should not re-evaluate the whole app) and async RAF rendering
  • Changes can easily be animated (also thanks to snabbdom)
  • Global and local state can optionally use Observables for greater composition
  • No JS class / this nonsense
  • Tiny size in KB
  • Comes with useful logs
  • First class support for typescript with a particular attention paid to type safety

Content

Components: step by step guide

kaiju adds the concept of encapsulated components to snabbdom's pure functional virtual dom. Standard Virtual nodes and components are composed to build a VNode tree that can scale in size and complexity. A VNode is what you get when calling snabbdom's h function for instance.

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, i.e, this is a valid array of VNodes:

[
  h('div'),
  myComponent({ someProp: 33 }),
  h('p', 'hello')
]

Note: typescript will be used in the examples, however the library also works just fine with javascript.

  1. We start with a stateless "component"
function button() {
  return h('button')
}
  1. For comparison sake, here is the simplest stateful component definition one can write:
import { Component, h } from 'kaiju'

export default function button() {
  return Component({ name: 'button', initState, connect, render })
}

function initState() { return {} }

function connect() {}

function render() {
  return h('button')
}

Now, that isn't terribly useful because we really want our component to be stateful, else we would just use a regular VNode object.

  1. Let's add some state, and make it change over time:
import { Component, h, Message, ConnectParams, RenderParams } from 'kaiju'


export default function() {
  return Component<{}, State>({ name: 'button', initState, connect, render })
}

interface State {
  text: string
}

function initState() {
  return { text: '' }
}


const click = Message('click')


function connect({ on }: ConnectParams<{}, State>) {
  on(click, () => ({ text: 'clicked' }))
}


function render({ state }: RenderParams<{}, State>) {
  return h('button', { events: { click } }, state.text)
}

Now we created a Message named click that is locally sent to our component whenever the user clicks on the button. We handle that message in connect and return the new state of our component. The component will then redraw with that new state.

Using explicit Messages instead of callbacks to update our state brings consistency with other kinds of (external) state management and makes state debugging easier since messages can be traced and logged (see logging).

In the above code, on(click) is in fact a shortcut for on(msg.listen(click)).

Here's the longer form:

function connect({ on, msg }: ConnectParams<{}, State>) {
  on(msg.listen(click), () => ({ text: 'clicked' }))
}

What msg.listen(click) returns is an Observable that emits new values (the payload of each message) every time the message is sent. This is very useful because observables can easily be composed:

function connect({ on, msg }: ConnectParams<{}, State>) {
  const clicks = msg.listen(click).debounce(1000)

  on(clicks, () => ({ text: 'clicked' }))
}

Now, the state is only updated if we stopped clicking for 1 second.

We could also decide to just perform a side effect, instead of updating the component's state. When performing side effects (void/undefined is returned) the component is not redrawn:

function connect({ on, msg }: ConnectParams<{}, State>) {
  const clicks = msg.listen(click).debounce(1000)

  on(clicks, _ => console.log('clicked!'))
}

Our component now has an internal state and we know how to update it. But it's also completely opaque from the outside! In a tree of VNodes, parents must often be able to influence the rendering of their children.

  1. For that purpose, we introduce props:
import { Component, h, Message, ConnectParams, RenderParams } from 'kaiju'


export default function(props: Props) {
  return Component<Props, State>({ name: 'button', props, initState, connect, render })
}

interface Props {
  defaultText: string
  paragraph: string
}

interface State {
  text: string
}

function initState(initProps: Props) {
  return { text: initProps.defaultText }
}


const click = Message('click')


function connect({ on }: ConnectParams<Props, State>) {
  on(click, () => ({ text: 'clicked' }))
}


function render({ props, state }: RenderParams<Props, State>) {
  return (
    h('div', [
      h('button', { events: { click } }, state.text),
      h('p', props.paragraph)
    ])
  )
}

Now our parent can render the component with more control: It can set the default text that should be displayed initially, but also directly sets the paragraph text of the p tag.

When composing components, you must choose which component should own which piece of state. Disregarding global state for now, local state can reside in a component or any of its parent hierarchy.

  1. Let's see how we can move the previous button text state one level up, so that the component parent can directly change that state:
import { Component, h, Message, ConnectParams, RenderParams } from 'kaiju'


export default function(props: Props) {
  return Component<Props, {}>({ name: 'button', props, initState, connect, render })
}

interface Props {
  text: string
  paragraph: string
  onClick: Message.OnePayload<Event>
}


function initState() {
  return {}
}


const click = Message('click')


function connect({ on, props, msg }: ConnectParams<Props, State>) {
  on(click, event => msg.sendToParent(props().onClick(event)))
}


function render({ props, state }: RenderParams<Props, {}>) {
  return (
    h('div', [
      h('button', { events: { click } }, props.text),
      h('p', props.paragraph)
    ])
  )
}

We now delegate and send a message to our direct parent component so that it can, in turn, listen to that message from its connect function and update its own state. Note: The child component could send the same Message to its parent (delegation) but we choose to go with an explicit onClick property to increase semantics, cohesion and typesafety.

At this point, the component is no longer stateful and providing it didn't have any other state, should probably be refactored back to a simple function returning a VNode:

interface Props {
  text: string
  paragraph: string
  onClick: Message.OnePayload<Event>
}

function button(props: Props) {
  const { text, paragraph, onClick } = props

  return (
    h('div', [
      h('button', { events: { click: onClick } }, text),
      h('p', paragraph)
    ])
  )
}

Finally, if we wanted a generic component we could declare it like so:

export default function<T>(props: Props<T>) {
  return select(props)
}

type Props<T> = {
  items: T[]
  selectedItem: T
  onChange: Message.OnePayload<T>
}

type State = {
  focusedIndex: number | undefined
}

const select = (function<T>() {

  function initState() {
    return { focusedIndex: undefined }
  }

  function connect({}: ConnectParams<Props<T>, State>) {}

  function render({}: RenderParams<Props<T>, State>) {
    return h('ul')
  }

  return function(props: Props<T>) {
    return Component<Props<T>, State>({ name: 'select', props, initState, connect, render })
  }

})()

Observables

kaiju comes with an implementation of observables (also known as streams) so that components can more easily declare how their state should change based on user input and any other observable changes in the application.

Observables are completely optional: If you are more confident with just sending messages around every time the state should update, you can do that too.

The characteristics of this observable implementation are:

  • Tiny abstraction, fast
  • OO style chaining
  • Multicast: All observables are aware that multiple subscribers may be present
  • The last value of an observable can be read by invoking the observable as a function
  • Synchronous: Easier to reason about and friendlier stack traces
  • No error handling/swallowing: No need for it since this observable implementation is synchronous
  • No notion of an observable's end/completion for simplicity sake and since we really have two kinds of observables: never ending ones (global state), and the ones that are tied to a particular component's lifecycle
  • Lazy resource management: An observable only activates if there is at least one subscriber
  • If the observable already holds a value, any subscribe function will be called immediately upon registration

To see observables in action, check the example's ajax abstraction and its usage

import { Observable } from 'kaiju'

const obs = Observable.pure(100).map(x => x * 2).delay(200)

The Observable OO API:

interface Observable<T> {
  /**
   * Subscribes to this observable values and returns a function that may be used to unsubscribe.
   */
  subscribe: (onValue: (val: T) => void) => () => void | void
  /**
   * Gives a debug name to the observable.
   * names are inherited by observable transforms unless a new name is defined downstream.
   */
  named: (name: string) => this

  /**
   * Reads the current value of the observable or returns undefined if no value was ever pushed in the observable.
   */
  (): T | undefined

  /**
   * Delays values until a certain amount of silence has passed.
   * Values in between silence periods are discarded.
   */
  debounce(wait: number): Observable<T>

  /**
   * Delays all values by a fixed time offset
   */
  delay(delay: number): Observable<T>

  /**
   * Creates a new Observable with adjacent repeated values removed.
   * A compare function can optionally be passed to implement user-defined equality instead of strict reference equality.
   * The functions should return true if the two values are equal.
   */
  distinct(compareFunction?: (previousValue: T, currentValue: T) => boolean): Observable<T>

  /**
   * Drops 'count' initial values.
   * Note: This can also be used to drop the initial value when subscribing to an Observable, if it had seen a value previously.
   */
  drop(count: number): Observable<T>

  /**
   * Filters the values of this observable.
   */
  filter<T>(predicate: (t: T) => boolean): Observable<T>

  /*
   * Maps and flattens this observable then only publish values from the observable mapped last.
   */
  flatMapLatest<B>(mapFn: (t: T) => Observable<B>): Observable<B>

  /**
   * Maps the values of this observable.
   */
  map<B>(mapFn: (t: T) => B): Observable<B>

  /**
   * Partitions this observable into two observables based on a predicate
   */
  partition<T>(predicate: (value: T) => boolean): [Observable<T>, Observable<T>]

  /**
   * Groups values in fixed size blocks (of size 2) by passing a "sliding window" over them.
   * An array becomes the value of the new observable. It will have a size of 1 the first time a value is produced,
   * then a size of 2 for subsequent values.
   * The newest value is always found at the index 0 of the Array for convenience and type safety.
   */
  sliding2(): Observable<[T, T | undefined]>

  /**
   * Groups values in fixed size blocks by passing a "sliding window" over them.
   * An array becomes the value of the new observable. It will have a size of 1 the first time a value is produced,
   * then an increasing size of max (maxWindowSize parameter) for subsequent values.
   * The newest value is always found at the index 0 of the Array for convenience (...rest parameters).
   */
  sliding(maxWindowSize: number): Observable<Array<T>>

  /**
   * Delays values so that values are produced at most once per every 'time' milliseconds
   */
  throttle(time: number): Observable<T>
}

The Observable static API:

interface ObservableObject {
  /** Creates a new Observable */
  <T>(activate?: (add: (t: T) => void) => UnsubFunction): SourceObservable<T>

  /**
   * Listens for DOM events at the specified parent element.
   * a childSelector can optionally be passed to listen to delegated events instead of direct events.
   */
  fromEvent(name: string, el: Element | EventTarget, childSelector?: string): Observable<Event>

  /**
   * Creates a new Observable from a Promise. The observable will produce only one value:
   * Either a { value: T } or an { error: any } object based on the result of the Promise.
   */
  fromPromise<T>(promise: Promise<T>): Observable<PromiseResult<T>>

  /**
   * Creates a new observable that produces undefined values at the provided interval in milliseconds.
   */
  interval(time: number): Observable<undefined>

  /**
   * Merges all the observables into one
   */
  merge<T>(...obss: Array<Observable<T>>): Observable<T>

   /**
    * Creates a new observable that produces one value immediately.
    */
  pure<A>(value: A): Observable<A>
}

Component lifecycle

Creation

  1. The component is now included in the application VNode tree for the first time
  2. initState() is called with the initial props
  3. connect() is called. Observables are plugged into the component, if they already hold state synchronously, the component's initial state is updated immediately.
  4. render() is called for the first time

Both initState and connect are called only once when the component first appears.

Update

  • At any point in time, the component will re-render if either is true:
    • The parent rerenders the component with changed props (shallow comparison)
    • An Observable registered in connect is updated, and an updated state (shallow comparison) is returned in its handler.

Synchronously sending a message to the component in its render method is forbidden to avoid loops.

Destruction

When some parent is removed from the tree or when the component's direct parent stops including the component in its render output, the component gets destroyed. render will never be called again and all the Observables are unregistered from.

Additionally, for any of these phases, the snabbdom hooks can be used on any VNode returned in render

If I want to

Initiate the component state from the initial props

Return the init state in initState:

function initState(props: Props) {
   return { enabled: props.isEnabledByDefault }
}

Continuously compute a part of the component state from its props (e.g perf optimization)

Derive some state from the props Observable in connect:

function connect({ on, props }: ConnectParams<Props, State>) {
   on(props, newProps => ({ statePart: expensiveOperation(newProps) }))
}

There is no need to also derive the state in initState, since props is an observable that always have an initial value (the handler will be called synchronously in connect).

Recompute the component state if its props changed in a specific way

This is a specialization of the above that avoid doing unnecessary work. We just have to remember the last props and compare it with the new ones:

function connect({ on, props }: ConnectParams<Props, State>) {
   on(props.sliding2, ([newProps, oldProps]) => {
      if (!oldProps || newProps.expr !== oldProps.expr)
        return ({ expr: parseExpr(newProps) })
   })
}

Note however that for inexpensive computations, it is generally advised to simply do it in render as it's then easier to guarantee props, state and view are in sync.

Perform a side effect when the component is added or removed

Use a snabbdom hook.

create is called before the element is added to the DOM, insert is called after the element is added the DOM, remove is called when the node's direct parent removes this node, destroy is called when this node is directly or indirectly being removed from the vnode tree.

function render() {
   return (
     h('div', { hook: { create: enterAnimation, remove: exitAnimation } })
   )
}

Alter the DOM when the component was rendered

Use the postpatch snabbdom hook.

function render() {
   return (
     h('div', { hook: { postpatch: fiddleWithTheDOMBehindYourBack } })
   )
}

Clean up a setInterval or remove a DOM event listener when the component is removed Good news everyone! You don't need to, if you use observables. Observables are automatically cleaned up when the component is removed.

function connect({ on }: ConnectProps<Props, State>) {
  const polling = Observable.interval(2000)
  on(polling, () => {
    // This handler will not be called once the component is removed
    callSomeAjax()
  })

  on(Observable.fromEvent('click', document.body), evt => {
    // This handler will not be called once the component is removed (the event handler is removed)
  })
}

You could also reproduce the same behavior imperatively:

import { Observable } from 'kaiju'

function connect({ on }: ConnectProps<Props, State>) {
  const pingTimeout = setTimeout(() => console.log('ping!'), 2000)

  const observeDestruction = Observable(_ => () => {

    /* This is the cleanup function that is called when there are no longer any subscribers
       to the observable. It will be  called when the component gets removed,
       provided the component was the sole subscriber */

    clearTimeout(pingTimeout)
  })
  on(observeDestruction, () => {})
}

Instantiate/destroy a vanillaJS widget when the component is added/removed

We can send a message from a DOM hook.

Assuming we found some vanillaJS widget named widget.Map that has a create and destroy method:

const inserted = Message<Element>('inserted')
const destroyed = Message('destroyed')

function connect({ on }: RenderParams<Props, State> & { context: Context }) {
  let mapWidget: MapWidget | undefined

  on(inserted, elm => { mapWidget = widget.Map.create(elm) })

  on(destroyed, () => mapWidget.destroy())
}

function render({ props, state, msg }: RenderParams<Props, State> & { context: Context }) {
  return (
    h('div', { hook: {
      insert: node => msg.send(inserted(node.elm)),
      destroy: () => msg.send(destroyed())
    }})
  )
}

Note that this kind of Message sent from a DOM lifecycle hook should always perform side effects only. If the state is updated, a warning will be logged and the component will ignore that change.

Local state vs Global state

Choosing whether a particular state is local or global, whether it's very local (component leaf) or not so local (owned by a component somewhere else in the tree) is an important decision, although it can easily be refactored based on new needs.

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. Less stateful components are more flexible because their parents can do what they want with that component, but stateful components are more productive and less error prone, as you can skip having boilerplate to wire the same parent state => component props everywhere it's used.

Example of typically local state

  • Whether a select dropdown is opened
  • Whether the component is focused
  • Which grid row is highlighted
  • Basically any state that resets if the user navigates away then comes 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. Else, you would have to manually clean up that state when exiting the component.

That just leaves global state, which can be updated from anywhere and is accessed from multiple screens.

Example of typically global state

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

Stores

A construct is provided to easily build push-based observables in a type-safe manner. This is entirely optional.

If you need a piece of state to live outside a component (it's not tied to a particular component's lifecycle), or you want your components to only care about presentational logic, you can either use Observables or Stores. The difference is that a Store's state can be updated from the outside via Messages and is guaranteed to have an initial value whereas an Observable can only be transformed via operators.

Example:

import { Store, Message } from 'kaiju'
import merge from './util/obj/merge' // Fictitious


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


interface UserState {
  name: string
}

const initialState = { name: 'bob' }

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

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

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

function connect({ on, state }: ConnectParams<{}, State>) {
  on(userStore.state, user => {
    // Copies 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 the store and the message
userStore.send(setUserName('Monique'))

connectToStore

Similarly to redux, a function is provided to create a new Component/function from an existing Component/function and a selector:

  import { Component, ConnectParams, RenderParams, Message, Store, connectToStore } from 'kaiju'

  const increaseBy = Message<number>('increaseBy')

  const initState = { num: 1 }

  const store = Store(initState, ({ on, state }) => {
    on(increaseBy, by => ({ num: state().num + by }))
  })

  type StoreType = typeof store

  type Props = {
    counter: number // From the store
    mode: '1' | '2' // From the direct parent
    opt?: string
  }

  const BaseComponent = (() => {

    function initState() { return {} }

    function connect({}: ConnectParams<Props, {}>) {}

    function render({ props }: RenderParams<Props, {}>) {
      renderedProps.push(props)
      return h('button')
    }

    return function(props: Props) {
      return Component<Props, {}>({ name: 'baseComponent', props, initState, connect, render })
    }
  })()

  const ConnectedComponent = connectToStore<StoreType>()(
    BaseComponent,
    store => ({ counter: store.state().num })
  )

  const connectedComponent = ConnectedComponent({ mode: '1', store })

It can only connect to a single store as you usually have a global store, or a very local one that can plug into other stores' data.

API

Creating a VNode with h

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

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

On top of the snabbdom modules you may feed to startApp, an extra module is always installed by kaiju: events. events is like snabbdom's own on module except it works with Messages instead of just any event handler.

import { Message } from 'kaiju'

const someMessage = Message<Event>('someMessage')

// Send a message to the enclosing component on click and on mousedown
h('div', { events: { click: someMessage, mousedown: someMessage } })

// Or prepare the message to be sent with an argument.
// This is more efficient than creating a closure on every render.
const anotherMessage = Message<[Event, {x: number}]>('anotherMessage')

h('div', { events: { click: anotherMessage.with({ x: 3 }) } })

Creating a component

The Component factory function takes an object with the following properties:

name

Mandatory String
This is the standard Virtual DOM key used in the diffing algorithm to uniquely identify this VNode.
It is also used for logging purposes, so it is usually just the name of the component.
By default, components have a key set to their name to differentiate them from other components. However, you can also set an external key by defining a key property inside the Component's props. The overall key will then be name + _ + your key. This can be useful when switching between two instances of the same component but without reusing any of its state.

sel

Optional String
An alternative hyperscript selector to use instead of component.

Component<Props, State>({ sel: `div.${styles.div}` })

props

Optional Object
An object representing all the properties passed by our parent.
Typically props either represents state that is maintained outside the component or properties used to tweak the component's behavior.
The render function will be called if the props object changed shallowly (any of its property references changed), hence it's a good practice to try and use a flat object.
Note 1: props and state are separated exactly like in React as it works great. The same design best practices apply.
Note 2: If you wish to compute some state or generally perform a side effect based on whether some part of the props changed (similar to using componentWillReceiveProps in react) you can use the sliding2 combinator to compare the previous props with the new ones:

import { Observable } from 'kaiju'

on(props.sliding2(), (state, [newProps, oldProps]) => ...)

initState

Mandatory Object
A function taking the initial props as an argument and returning the starting state.
Note: Any synchronous observables further modifying the state in connect will effectively change the state used for the first render.

connect

Mandatory function({ on, msg, props, state }: ConnectParams<Props, State>): void
Connects the component to the app and computes the local state of the component.
connect is called only once when the component is mounted.

connect is called with four arguments, encapsulated in a ConnectParams object:

  • on registers a Message or Observable that modifies the component local state. The Observable will be automatically unsubscribed from when the component is unmounted.
    Returning the current state or undefined in an on handler will skip rendering and can be used to do side effects.

  • msg is the interface used to send and listen to messages.

Full interface:

/**
 * Listens for a message sent from local VNodes or component children
 */
listen<P>(message: Message<P>): Observable<P>

/**
 * Listens to all messages bubbling up to a particular DOM node
 *
 * Example:
 * const overlayMessages = msg.listenAt('#popupLayer .overlay')
 *
 * Note: The DOM Element must be available at the time the function is called.
 */
listenAt<P>(target: string | Element): Observable<MessagePayload<{}>>

/**
 * Sends a message to self.
 *
 * Example:
 * msg.send(AjaxSuccess([1, 2]))
 */
send<P>(payload: MessagePayload<P>): void

/**
 * Sends a message to this component's nearest parent.
 *
 * Example:
 * msg.sendToParent(ItemSelected(item))
 */
sendToParent<P>(payload: MessagePayload<P>): void
  • props An Observable with a new value every time the props passed by our parent changed. It is often enough to simply let the render function take care of these new props but advanced users may sometimes want to derive some state from props.

Just like with props, a redraw will only get scheduled if the state object changed shallowly.

  • state The Observable current state.

render

Mandatory function({ props, state, msg }: RenderParams<Props, State>): VNode | Node[]

Returns the current VNode tree of the component based on its props and state. You can also return an Array of Nodes, where a Node is either a VNode, a string, null or undefined.

Example:

import { h, Message, RenderParams } from 'kaiju'

interface State {
  text: string
}

const buttonClick = Message<[Event, number]>('buttonClick')

function render({ state }: RenderParams<void, State>) {
  const { text } = state

  return (
    h('div#text', [
      h('h1', 'Hello'),
      h('p', text),
      h('button', { events: { click: buttonClick.with(33) } })
    ])
  )
}

startApp

Installs and 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
  replaceElm?: boolean // Whether elm should be fully replaced by the app, instead of acting as the app parent. Defaults to false.
  snabbdomModules: any[] // The snabbdom modules that should be active during patching
}): void
import { startApp } from 'kaiju'
import app from './app'


const snabbdomModules = [
  require('snabbdom/modules/class'),
  require('snabbdom/modules/props'),
  require('snabbdom/modules/attributes'),
  require('snabbdom/modules/style')
]

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

For more information about snabbdom modules see the official documentation

Render.into

This function is made available after the app was created with startApp.
This can be used to create some advanced components with their own internal rendering needs (e.g: Efficient popups, alerts, etc). It renders either synchronously if called from an ongoing rendering phase, or asynchronously.

import { Render, h } from 'kaiju'

const firstVDom = h('div')

// Creates a new div as a child of body
Render.into(document.body, firstVDom)

// Patch that div so that it becomes a span
const cancel = Render.into(firstVDom, h('span'), () => {
  // Inside the rendered callback
})

Render.scheduleDOMWrite

The virtual DOM abstraction pretty much guarantees an optimal way of creating and updating the DOM in a single pass, without any layout trashing. Sometimes, however, you may want to further alter the DOM in an imperative way when it's not possible to have a straightforward state->view binding. Kaiju provides two functions to do that without causing layout trashing: Render.scheduleDOMRead and Render.scheduleDOMWrite. Both are called at the end of a render cycle (so still inside a requestAnimationFrame context) The reads and writes are batched, reads are called first.

import { Render, VNode } from 'kaiju'

// Called within an insert hook
function increaseHeight(vnode: VNode) {
  const el = vnode.elm as HTMLElement

  Render.scheduleDOMRead(() => {
    const height = el.clientHeight

    Render.scheduleDOMWrite(() => {
      el.style['height'] = `${height + 10}px`
    })
  })
}

Message

Stores and Component can both send and listen to message. Indeed, each component has a private Store to manage its state. Messages help debugging and communicate intent better than generic model-altering callbacks. Here's what you can do with messages:

Creating a custom application message used to either communicate between components or send to a Store.

import { Message } from 'kaiju'

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

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

// Message taking a tuple
const incrementBy2 = Message<[number, Event]>('incrementBy')
// Then pre-bind it so it can be used directly in the DOM:
incrementBy2.with(33) // Message<Event>

To store references of messages with a specific number of payloads, use:

const msg0: Message.NoPayload

const msg1: Message.OnePayload<string>

const msg2: Message.TwoPayloads<string, number>

Sending a message to a Store instance (usually to update application/domain state)

See store.send

  • Sending a message to the current Component
function connect({ on, msg, props, state }: ConnectParams<Props, State>) {
  on(click, evt => msg.send(sheReallyClicked(evt))
}

Sending a message to the nearest parent Component

function connect({ on, msg, props, state }: ConnectParams<Props, State>) {
  // Whenever a click message is received by this component, notify our parent.
  // We read which message should be sent from the Props as it should be our parent's decision.
  on(click, evt => msg.sendToParent(props().onClick(evt)))
}

Create an Observable for all messages of a given type

msg.listen creates an Observable publishing every Message of that type. This can be useful to transform the observable before handling the Message or creating reusable abstractions.

function connect({ on, msg, props }: ConnectParams<Props, State>) {

  const clicks = msg.listen(click).debounce(800)

  on(clicks, evt => console.log(evt))
}

Listen to all Messages bubbling up a particular DOM Element

This should rarely be useful. It can be used when a Component (e.g a popup) renders its content in another part of the DOM tree and Messages should be listened from there instead of locally.

function connect({ on, msg, props }: ConnectParams<Props, State>) {

  const messagesFromPopup = msg.listenAt('#popup')

  on(messagesFromPopup, message => console.log(message))
}

Partially apply a Message's payload

Use cases

  1. Reuse a Message inside a Component's VDOM but with a different payload
  2. Set part of the payload of a child's callback Message with information only useful to the parent (e.g, which child was this?)
const onClick = Message<string, MouseEvent>('onClick')

function render() {
  return h('button', {
    events: {
      click: onClick.with('John')
    }
  })
}

Note 1: Partially applying a Message has a little performance cost, roughly equal to a lambda creation. However, unlike in some other VDOM frameworks, the component will not re-render if the payload wasn't actually changed.

Note2: A partially applied Message is only to be used for sending, not receiving. Always listen to the original Message.

Catching unhandled messages

Messages sent by the events snabbdom module or when using Messages.sendToParent will bubble up the DOM till it finds the nearest parent component. Sometimes, this is not wanted as the nearest component could be a generic component that shouldn't listen to your business messages, only to its own messages.

For instance, inside a utility component, we could forward any messages we're not interested in to our parent (e.g explicit bubbling):

function connect({ on, msg, props, state }: ConnectParams<Props, State>) {

  on(click, evt => update(state(), { text: 'clicked!' }))

  on(Message.unhandled, message => msg.sendToParent(message))

}

Logging data changes and render timing

kaiju has useful logging to help you debug or visualize the data flows.

log-example

By default, nothing is logged, but that can be changed:

import { log } from 'kaiju'

log.render = true
log.message = true

Additionally, you can specify which component gets logged using the component's name:

log.render = 'select'
log.message = 'popup'

You will want to change the log values as early as possible in your program so that no logs are missed.

Note: The render durations are more interesting as a relative measurement to spot bottlenecks and focus any optimization effort. The absolute durations may be heavily influenced by the console itself sometimes being very slow.

slow-console

#Full TS Example

A full application example using TypeScript