synks

Asynchronous view renderer

Usage no npm install needed!

<script type="module">
  import synks from 'https://cdn.skypack.dev/synks';
</script>

README

Synks

Synks is a tiny javascript view renderer, that is built async first components.

It uses JSX/hyperscript for it's templating. Functions, promises and generators for component composition. And classes for contexts.

npm version npm downloads npm bundle size (version) GitHub Workflow Status

Installation

To install the stable version:

npm install --save synks

This assumes you are using npm as your package manager.

If you're not, you can access these files on unpkg, download them, or point your package manager to them.

Documentation

I'm assuming you already know what JSX is and how to set it up with custom pragma (Synks.h), so let's cut the chase and start with component composition.

Hello World example

For simple output we can use simple functional component.

function Hello({ what }) {
  return <h1>Hello {what}</h1>;
}

Synks.mount(<Hello what="World" />, document.body);

This example will mount h1 to body like so <body><h1>Hello World</h1></body>

Counter example

For this we'll need to use generators as they can have state inside them.

function *Counter() {
  let count = 0;

  const increment = () => {
    count++;
    this.next();
  }

  while(true) {
    yield (
      <button onclick={increment}>
        {count}
      </button>
    )
  }
}

Data fetch example

To handle async data fetching, we can use async functional component for this.

async function Movies() {
  const movieList = await api.getMovieList();

  return (
    <ul>
      {movieList.map((movie) => (
        <li>{movie.title}</li>
      ))}
    </ul>
  );
}

NOTE: This will halt all rendering of it's sibling components.

Suspense example

This will allow sibling components to render even when all of the children's here are not yet ready.

function Loading() {
  return <div>Loading...</div>;
}

async function *Suspense({ fallback, children }) {
  // Here we are saying when fallback is mounted, go to next yield.
  this.onMount = this.next;

  while (true) {
    yield fallback;
    yield (
      <div>{children}</div>
    );
  }
}

This will not stop DOM rendering at async components.

Context example

And finally we can use context to sync multiple components with ony state. For this we need to extend Synks.Context class to build our own context.

class CountContext extends Synks.Context {
  count = 0;
  increment() {
    this.count += 1;
  }
}

count is a state variable that defaults to 0 and increment is a method that increments that count variable. Easy.

Now let's use this context.

Synks.mount(
  <div>
    <CountContext>
      <StateCounter />
      <div>
        <StateCounter />
      </div>
    </CountContext>
  </div>
);

No extra steps or requirements here, just wrap your context around child components that will use this context.

Ok, this is not that hard, but where's the catch? Do I need to do some extra stuff when using context in actual component? Well, no. Let's look at our StateCounter we used inside CountContext.

function *StateCounter() {
  const countContext = yield CountContext;

  while (true) {
    yield (
      <button onclick={countContext.increment}>
        {countContext.count}
      </button>
    )
  }
}

Ok so when you yield a context it automatically returns corresponding context.

NOTE: Do not destruct countContext as it will be transformed to simple value and not be updatable.

Here's a more in depth example of Contexts: stackblitz.com/edit/vite-6agmqe

Code reuse (mixins vs hooks story)

Let's take for example React hooks. You can create function and reuse that function in multiple components. Hooks can trigger updates, so basically it is extension of component.

Ok, lets take a look at how mixins usually work. You create also some kind of a function and then this function or methods of this mixin gets used in component.

So basically these are kind of 2 different solutions. I might have gone the route of either one of these, but instead I think I've found a solution for this that takes the best of both worlds.

I currently call them hooks internally as they work more like hooks.

It's just a generator function with ability to get parent components scope.

Let's create our first hook, that will listen to keypressed events and increment value accordingly.

function* countHook() {
  /**
   * First of all lets get scope of parent component.
   * This will allow us to call `scope.next()` to update
   * component, just like we do in components.
   */
  const scope = yield Synks.SCOPE;
  let count = 0;

  // If pressed key is our target key then set to true
  const downHandler = ({ key }) => {
    if (key === 'ArrowUp') {
      // Increment count state
      count++;
      // And update component
      scope.next();
    }
  }

  // Add event listeners
  window.addEventListener('keydown', downHandler);

  scope.onDestroy = () => {
    // Remove event listeners on cleanup
    window.removeEventListener('keydown', downHandler);
  }

  while (true) {
    // Return count value back to component
    yield count;
  }
}

Ok this is how we create hook, but how do we use it?

function* Counter() {
  const count = yield countHook();

  while (true) {
    yield (
      <h1>
        {count.value}
      </h1>
    );
  }
}

This example available in stackblitz.com/edit/vite-cdemev

This is it, now it's fully functional - locally scoped hook used in Counter component.

Note that we use count.value instead of count, because this is what generator functions return. Also this helps to pass new value without calling countHook in while loop.

But that is not all!

Hooks can also use Context!

function* countHook() {
  const countContext = yield CountContext;

  const downHandler = ({ key }) => {
    if (key === 'ArrowUp') {
      countContext.increment();
      // We don't need to call `.next` here because
      // context updates components itself
    }
  }

  window.addEventListener('keydown', downHandler);
}

Architecture

Synks renders everything to DOM asynchronously. This means every exported method except h returns Promise. It waits for every component in it's child tree to be ready and only then it renders it.

This is powerful when using async data fetching and syncing all dom tree together.

It doesn't do incremental rendering, except when you specifically allow it with suspended async generators.

For re-rendering - when updating parent component using this.next method, it's child components will ONLY be re-rendered again if props actually change.

Context changes will only trigger update for components that are using that specific Context, not the whole context tree.

License

MIT

Copyright (c) 2020, Marcis (Marcisbee) Bergmanis