Skip to content

MVCoconut/coconut.ui

Repository files navigation

Coconut UI Layer

Gitter

This library provides the means to create views for your data. It shares significant similarities with React. One of them is its API, which has increasingly converged with React's for higher familiarity, easier porting and better interoperability with react. Furthermore, just like React requires e.g. react-dom to render to the DOM, coconut also must be accompanied by a rendering backend:

  • coconut.vdom: a hand crafted virtual dom renderer that trumps React in speed and size.
  • coconut.react-dom: an adapter to render coconut views through ReactDOM allowing you to leverage React's vast ecosystem.
  • coconut.react-native: an adapter to render coconut views through React Native allowing you to develop mobile apps.
  • coconut.haxeui: an adapter to render coconut views through HaxeUI.
  • coconut.h2d: [EXPERIMENTAL] an adapter to render coconut views through Heaps.
  • coconut.awt: [EXPERIMENTAL] an adapter to render coconut views through Java AWT/Swing.

Coconut views use HXX to describe their internal structure, which is primarily driven from their render method. This is what a view basically looks like:

class Stepper extends coconut.ui.View {

  @:attribute var step:Int = 1;
  @:attribute function onconfirm(value:Int);
  @:state var value:Int = 0;

  function render() '
    <div class="counter">
      <button onclick={value -= step}>-</button>
      <span>{value}</span>
      <button onclick={value += step}>+</button>
      <button onclick={onconfirm(value)}>OK</button>
    </div>
  ';
}

A function with just a string body is merely a syntactic shortcut for a function with return hxx('theString'). So if you want to be more explicit or do something else in your rendering function, you could write:

class Stepper extends coconut.ui.View {

  @:attribute var step:Int = 1;
  @:attribute function onconfirm(value:Int);
  @:state var value:Int = 0;

  function render() {
    trace("rendering!!!");
    return hxx('
      <div class="counter">
        <button onclick={value -= step}>-</button>
        <span>{value}</span>
        <button onclick={value += step}>+</button>
        <button onclick={onconfirm(value)}>OK</button>
      </div>
    ');
  }
}

The promise of coconut.ui is: whenever your data updates, your view will update also. This assumes that you do not defeat coconut's ability to observe changes.

Every view has a number of attributes and states, that we'll look at in detail below. If you have a passing familiarity with React, you can roughly think of the attributes being the props and the states being the state.

Attributes

Attributes represent the data that flows into your view from the outside (usually the model layer or a parent view) and callbacks that allow the view to report changes. The above Stepper example has one of each.

You define attributes in one of the two following ways:

  • prefix a field with @:attribute or @:attr and optionally a default value after a =.
  • define a single attributes pseudo-field, where again you have two options of defining defaults:
    • if the type is an anonymous object defined inline, "initialize" the fields
    • otherwise "initialize" the pseudo-field with an object literal.

The following things mean the same:

  //as used in the above Stepper:

  @:attribute var step:Int = 1;
  @:attribute function onconfirm(value:Int):Void;

  //is equivalent to:

  var attributes:{
    var step:Int = 1;
    var onconfirm:tink.core.Callback<Int>;
  };

  //is equivalent to:

  var attributes:StepperAttributes = { step: 1 };
  //where
  typedef StepperAttributes = {
    var step:Int;
    var onconfirm:tink.core.Callback<Int>;
  }

Controlled Attributes

Sometimes a view reads from and writes to a single property, such as an @:editable property of a model, or the @:state of a parent. One solution to this is to pass down the data, as well as a callback for when the view wants the property to change. A shorter, and semantically more explicit alternative is to define controlled attributes:

class Key extends View {
  @:attribute var value:Int;
  @:controlled var current:Int;
  function render() '
    <button class=${{ selected: value == current }} onclick=${current = value}>$value</button>
  ';
}

class KeyPad extends View {
  @:state var value:Int = 0;
  static var max = 10;
  function render() '
    <div>
      <for ${i in 0...max}>
        <Key value=$i current=${value} />
      </for>
    </div>
  ';
}

As you see Key::current is passed as an attribute in KeyPad, but Key can alter it, which will take effect on the parent's state.

Currently, controlled attributes cannot be passed down via spreads.

Children

Views may also consume children, which are handled very much like attributes in almost every way, except in how they're specified in HXX.

The following are all equivalent:

class Button extends View {
  @:attribute function onclick():Void;
  @:attribute var children:String;
  function render() '
    <button onclick={onclick}>{children}</button>
  ';
}

class Button extends View {
  @:attribute function onclick():Void;
  @:children var label:String;
  function render() '
    <button onclick={onclick}>{label}</button>
  ';
}

class Button extends View {
  @:attribute function onclick():Void;
  @:child var label:String;
  function render() '
    <button onclick={onclick}>{label}</button>
  ';
}

And you would use any of them like so:

<Button onclick={trace("World!")}>Hello</Button>

Implicit attributes

Implict attributes serve the same purpose as react context. Let's take a look first:

@:default(Theme.LIGHT)
class Theme {

  static public final LIGHT = new Theme('#333', '#f8f8f8');
  static public final DARK = new Theme('#eee', '#444');

  public final foreground:Color;
  public final background:Color;

  public function new(foreground, background) {
    this.foreground = foreground;
    this.background = background;
  }
}

class MyUi extends View {
  @:implicit var theme:Theme;
  function render() '
    <div style=${{ background: theme.background, color: theme.foreground }}>
      <button style=${{ background: theme.foreground, color: theme.background, border: 'none' }}
    </div>
  ';
}

By default, MyUi will render with the light theme, as determined by @:default(Theme.LIGHT). To achieve the opposite effect, you can do the following:

  1. @:implicit var theme:Theme = Theme.DARK; - this way, the default theme will be dark, no matter what's defined globally for Theme via @:default.
  2. <Implicit defaults={[ Theme => Theme.DARK ]}><MyUi /></Implicit> - this will use the dark theme as a default for all children of that <Implicit /> (much like a context provider in react)
  3. <MyUi theme=${Theme.DARK}> - this will explicitly make MyUi use the dark theme. Note that this will not affect the default for child views.

Precedence (decreasing):

  • explicitly passed attribute
  • implicit defaults set via <Implicit />
  • default value defined in view
  • default value defined via @:default on the target type

There are a few restrictions in place:

  1. implicit values must be observable (or constant)

  2. implicit values must be instances or enum values. Anonymous objects are (currently) not supported.

  3. any @:implicit field requires a default to be declared either in place or via @:default on the type. Lack of a default leads to compiler failure. This is to statically ensure a value is available.

  4. implicit values must be defined for the exact type, i.e. you cannot have this:

    interface I {}
    @:default(new A())
    class A implements I {
      public function new() {}
    }
    class Foo extends View {
      @:implicit var i:I;// I has no default
    }

    This is not possible, because there could be

    @:default(new B())
    class B implements I {
      public function new() {}
    }

    In this case it's not possible to know which class should be the default for the interface. You can however define a @:default on I that uses any suitable implementor.

States

States are internal to your view. They allow you to hold data that is only relevant to the view itself, but is still observable from the framework's perspective. In the Stepper example, clicking on the - button will decrement value and this will in turn cause a rerender that is going to update the content of the span that shows the current value to the user.

Your views may also hold plain fields for whatever purpose. Note though that updates to those will generally not cause rerendering.

If for whatever reason you want to skip observability checks (to have a constant-like attribute), use @:skipCheck. It works on individual fields or whole types. Either:

  • @:skipCheck @:attr var myConstant:SomeClassWithMutableStuff;
  • or @:skipcheck class SomeClassWithMutableStuff { ... }

Laziness, granular invalidation and batched rerendering

Unless the particular renderer diverges from the norm, the following can be said about how views update:

  • attributes passed to views are not evaluated unless the view consuming them evaluates them (or passes them to a child that evaluates them). Example:

    class Foo extends coconut.ui.View {
      @:attribute var foo:Int;
      function render() '<div/>'
    }
    
    hxx('<Foo foo=${throw "you will never see this"}/>');

    Because foo is never used, it's not evaluated and the exception is never raised.

  • Changes to any state or attribute accessed directly or indirectly in a view's render function will invalidate the view. Any other changes have no effect on the view itself.

  • Passing states/attributes to child views does not count as access. Consider the following contrived example:

    class Button extends coconut.ui.View {
      @:attribute var children:coconut.ui.Children;
      @:attribute function onclick():Void;
      function render() '
        <button onclick=${onclick}>${...children}</button>
      ';
    }
    
    class Container extends coconut.ui.View {
      @:state var a:Int;
      @:state var b:Int;
      var renderCounter = 0;
      function render() '
        <div>
          Rendered ${renderCounter++} times
          <Button onclick=${a++}>${a}</Button>
          <button onclick=${b++}>${b}</button>
        </div>
      ';
    }

    Note: having side effects such as renderCounter++ in your render function is bad practice, but here it's meant to illustrate whether or not the component rerenders.

    What's important to note about this example is that clicking on the Button will not rerender Container (but only the Button), while clicking on the plain button will. You can use this behavior to contain the effect of state updates. There is a very simple view included in coconut.ui to leverage just that:

    package coconut.ui;
    
    class Isolated extends View {
      @:attribute var children:RenderResult;
      function render() return children;
    }

    You would use it like so:

    class Container extends View {
      @:state var a:Int;
      @:state var b:Int;
      var renderCounter = 0;
      function render() '
        <div>
          Rendered ${renderCounter++} times
          <Isolated><button onclick=${a++}>${a}</button></Isolated>
          <button onclick=${b++}>${b}</button>
        </div>
      ';
    }

    Clicking the button wrapped in Isolated will not rerender Container.

  • Changes do not instantly rerender views, but invalidate them, which schedules a batched update with the browser's next animation frame (or frame on NME/OpenFl or short timeout on other platforms). This bares some similarity with React's async rendering.

    It's important to understand though that on one hand invalidation is an event that eagerly cascades through your dependencies, but all computation is batched, including attributes. If your view invokes a callback that is likely to change the value of an attribute, the attribute is recomputed only when accessed, either in rendering or other code you write to access it.

@:tracked states and attributes

On occasion, you may wish for a certain state or attribute to cause a rerender, regardless of whether or not it was accessed in the render function. The most common case is a revision counter that is bumped when some value that is not (directly) observable has changed (e.g. your view's size on screen). Regardless of the use case, if you mark a state/attribute as @:tracked then changes to it will cause invalidation.

You may also specify expressions as parameters, with _ taking the place of the attribute to track sub-expressions.

Example:

@:tracked(_.get('Paris').population)
@:attribute var cities:ObservableMap<String, City>;

Refs

Just like React, coconut supports refs to get access to the elements/views you're creating.

'
  <div ref=${div -> if (div != null) console.log(div.innerHTML)}>
    <Stepper ref=${stepper -> trace(stepper.step)} />
  </div>
'

It is advised to use methods rather than anonymous functions for performance reasons.

@:ref syntax

You may also define refs like so:

class Counter extends View {

  @:ref var button:ButtonElement;
  @:state var counter:Int = 0;

  function render() '
    <button ref=${button} onclick=${counter++}>${counter}</button>
  ';

  function viewDidUpdate() {
    trace(button);//Will log <button>1</button> the first time you click.
  }
}

It is in fact possible to pass in any valid left hand value for an assignment, although that will also cause the creation of an anonymous function, which you want to avoid. Using @:ref avoids this and also makes the reference read only and thus safer to rely on.

Life cycle callbacks

Coconut views may declare life cycle callbacks, which are modelled after those in React, adjusted for the naming differences:

What React calls component and props, Coconut calls views and attributes respectively, as those are more specific terms: the term component can mean anything and in ECMAScript terminology, the state of a React component is a property.

viewDidMount

function viewDidMount():Void;

This callback is invoked after the component is mounted into the DOM (or whatever the native view hierarchy might be). It corresponds to React's componentDidMount

shouldViewUpdate

function shouldViewUpdate():Bool;

This function is invoked to determine if a component should rerender. While it mostly corresponds to React's shouldComponentUpdate, in contrast to React, it not pass nextState and nextProps. Instead, state and attributes changes are always applied before this function is invoked.

Caveat: if this function returns false, the view will only invalidate if any of the states or attributes that this function depends on changes (or any @:tracked attributes or states change).

This function exists only for optimization purposes.

getDerivedStateFromAttributes

static function getDerivedStateFromAttributes(states:States, attributes:Attributes):Partial<States>;

This function is called right before rendering and is expected to return an object, that may define a new value for each state. It corresponds to React's getDerivedStateFromProps.

getSnapshotBeforeUpdate

function getSnapshotBeforeUpdate():Snapshot;

This function is called after render, before the resulting changes take effect. Note that Snapshot is not a particular data type. You may either be explicit about it, otherwise it will be inferred by the compiler. Corresponds to React's getSnapshotBeforeUpdate, but note that prevState and prevProps are not passed. If you need these, you will have to track them yourself.

viewDidUpdate

function viewDidUpdate(snapshot:Snapshot):Void;

This callback is invoked after the updates resulting from render take effect.

The function has 0 parameters if you don't declare getSnapshotBeforeUpdate and 1 if you do. If you don't declare the parameter, a parameter called snapshot is created implicitly. If you don't explictly define the type of the one parameter, it will implicitly be inferred to the return type of getSnapshotBeforeUpdate.

While viewDidupdate mostly corresponds to React's componentDidMount, prevState and prevProps are not passed. If you need these, you will have to track them yourself.

viewDidRender

function viewDidRender(firstTime:Bool):Void;

This callback is invoked every time after the results of render are applied to the physical UI (e.g. DOM), with the passed boolean being true for the first call and false for all subsequent calls. You can use this as a combination of viewDidMount and viewDidUpdate.

viewWillUnmount

function viewWillUnmount():Void;

This callback is invoked before the view is unmounted and corresponds to While viewDidupdate mostly corresponds to React's componentWillUnmount.

Consider using untilUnmounted/beforeUnmounting instead.

untilUnmounted or beforeUnmounting

function untilUnmounted(cb:Callback<Noise>):Void;
function beforeUnmounting(cb:Callback<Noise>):Void;

One possibility (idiomatic in React) for cleaning up a view is to store any allocated resources in instance fields and then access them in viewWillUnmount, e.g.:

class Example extends View {
  var map:MutationObserver;
  function viewDidMount() {
    observer = new MutationObserver(...);
    observer.connect(...);
  }
  function viewWillUnmount() {
    observer.disconnect();
    observer = null;
  }
}

An alternative is to use untilUnmounted/beforeUnmounting (which are fully equivalent and should be picked depending on what reads more naturally) which take a Callback<Noise> that is executed before unmounting. So for example the code above would be written like so:

class Example extends View {
  function viewDidMount() {
    var observer = new MutationObserver(...);
    observer.connect(...);
    beforeUnmounting(observer.disconnect);
  }
}

That's shorter and avoids having instance fields that clutter completion. Another way to write the same is:

class Example extends View {
  function viewDidMount()
    untilUnmounted(() -> {
      var observer = new MutationObserver(...);
      observer.connect(...);
      observer.disconnect;
    });
}

This is absolutely equivalent with the previous version. The latter name makes most sense when used a call that returns a CallbackLink from tink_core. Let's assume we define something like this:

class Observe {
  static function mutations(target:Element, cb:Callback<Element>):CallbackLink {
    //... set up mutation observer here
  }
}

The we can use it like so:

class Example extends View {
  @:ref var root:Element;//Need to populate this in `render` of course
  function viewDidMount()
    untilUnmounted(Observe.mutations(root, () -> {
      //do something
    }));
}

untilNextChange or beforeNextChange

These two are anologous to untilUnmounted/beforeUnmounting, except that they fire before unmounting and before rerendering. Use these if you need to setup behavior that is cleaned up any time the component changes. Let's consider this rather silly view, that may change it's underlying root element every time it rerenders:

class Example extends View {
  @:ref var root:Element;

  function render() '
    <if ${Math.random() > .5}>
      <button ref=${root} />
    <else>
      <textarea ref=${root} />
    </if>
  ';

  function viewDidMount()
    untilNextChange(Observe.mutations(root, () -> {
      //do something
    }));
}

afterUpdating

function afterUpdating(cb:Void->Void):Void;

If you wish to run a function after the next update, you can schedule it per afterUpdating.

Avoiding typos in life cycle callbacks

To avoid typos when declaring life cycle callbacks, coconut warns if it sees functions that have names similar to the supported callbacks. To make absolutely sure your callback is correctly named, you may add override which is de-facto certain to cause an error if you mistype the name.

Renderer API

Renderers expose the following API.

package coconut.ui;

class Renderer {
  /// Mounts a part of vdom into the dom
  static function mount(target:js.html.Node, vdom:RenderResult):Void;
  /// Gets the native view (DOM node) corresponding to a given View (consider using refs instead)
  static function getNative(view:View):Null<js.html.Node>;
  /// Forces the synchronous update of all currently invalidated views
  static function updateAll():Void;
}

The above Renderer.mount and Renderer.getNative are equivalent to ReactDOM.render and ReactDOM.findDOMNode.

About

Wow, such reactive view! Much awesome!

Resources

License

Stars

Watchers

Forks

Packages

No packages published