npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

alterisk

v0.1.5

Published

A generator driven component api for (p)react and more!

Downloads

20

Readme

What is this about?

alter* is an attempt to provide an (async) generator driven alternative component model for preact, react and more (you can create your own integration).

The idea to add this on top of existing ui frameworks was on the back of my mind for a while (ever since I started working on my own generator driven framework enth.js) and the general interest of the community in Crank.js finally made me give it a try.

This project is mostly experimental at this stage and I'm publishing it early to gather some feedback.

Okay, but how does it look?

The following is a simple, contrived example of an async generator component on top of preact+htm. As you can see, alter* lends itself for modelling multi step components that may even include async steps (no suspense needed):

run on stackblitz

import {
  createPreactComponent,
  render,
  html,
  h,
  createObservable,
  withObservables,
  withPromise,
  $layoutEffect,
} from "alterisk/preact";
import { css } from "goober";
import { Card, Center, Form } from "./misc";

// this is an example work flow using alter* with a fake signup form
const Signup = createPreactComponent(function* () {
  // an observable is a proxified objects that emits change events when any of it's (deep) properties changes
  // you could submit to these with formData.on(value => console.log(value))
  const formData = createObservable({
    email: "",
    password: "",
    avatar: false,
    step: 0, // identifies what step in the signup process we are in
    // step 0: enter email + password
    // step 1: upload avatar
    // step 3: we submit the form
  });

  // this is similiar to (p)reacts useLayoutEffect hook
  // it runs whenever the result of the second function returns a change in the dependency array
  // you may also return a cleanup function from the effect, just like in useLayoutEffect
  // careful: you only need to execture $layoutEffect once and not on every render, so don't put it inside the while loop below
  $layoutEffect(
    () => {
      document.title = `Step: ${formData.step}`;
    },
    () => [formData.step]
  );

  // while the user still needs to enter data, we remain in steps 0 and 1
  while (formData.step < 2) {
    switch (formData.step) {
      case 0:
        // withObservables renders the passed view and then waits for the passed observable to change to re-render
        yield withObservables(renderStep1(), formData);
        break;
      case 1:
        yield withObservables(renderStep2(), formData);
        break;
    }
  }
  // after step 1 is done, we break out of the loop and (fake) submit the form
  // withPromise triggers a re-render when the passed promise resolves
  // until then, we render a loading spinner
  const success = yield withPromise(renderLoadingSpinner(), submitSignUpForm());

  // handle the response
  if (success) {
    // successfully signed up, render a success message!
    yield html`
      <${Center}>
        <${Card}>
          (fake) sign-up successful!
        </${Card}>
      </${Center}>`;
  } else {
    // do something else (we're skipping this part)
  }

  function renderStep1() {
    const canSubmit = formData.email.length > 3 && formData.password.length > 3;
    return html`
        <${Center}>
          <${Card}>
            <div class="instructions">Please sign up here via our (fake) form:</div>
            <${Form} autocomplete="off">
              <input 
                type="text" 
                placeholder="email" 
                value=${formData.email} 
                oninput=${(e) => (formData.email = e.target.value)} />
              <input class="avatar" type="password" 
                placeholder="password" 
                value=${formData.password}   
                oninput=${(e) => (formData.password = e.target.value)} />
              <div class="submit">
                <button 
                  disabled=${!canSubmit} 
                  onclick=${(e) => formData.step++}>next: select an avatar
                </button>
              </div>
            </${Form}>
          </${Card}>
        </${Center}>`;
  }

  function renderStep2() {
    const canSubmit = formData.avatar;
    return html`
        <${Center}>
          <${Card}>
            <div class="instructions">Please upload an avatar picture:
              <div class=${css`
                font-size: 10px;
              `}>
                (we're not acctually uploading anything)
              </div>
            </div>
            <${Form} autocomplete="off">
            <input type="file" 
              name="avatar"
              accept="image/png, image/jpeg" 
              onchange=${(e) => (formData.avatar = true)}
              />
              <div class="submit">        
                <button 
                  class="previous"
                  onclick=${(e) => formData.step--}>back
                </button>
                <button 
                  disabled=${!canSubmit} 
                  onclick=${(e) => formData.step++}>submit
                </button>
              </div>
            </${Form}>
          </${Card}>
        </${Center}>`;
  }

  function renderLoadingSpinner() {
    return html`
        <${Center}>
          <${Card}>
            <div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>
          </${Card}>
        </${Center}>`;
  }

  function submitSignUpForm() {
    return new Promise((resolve) => setTimeout(() => resolve(true), 2000));
  }
});

render(html` <${Signup} /> `, document.body);

API

The API added on top of a framework by aster* is fairly simple. I will explain it using the provided preact integration.

Observables

Observables are proxified objects. They include an on method for listening to changes. Any change on (deeply nested) properties of the object will fire the change event:

run on stackblitz

import { createObservable } from "alterisk/preact";

// create a new observable
const observable = createObservable({
  count: 0,
});
// count observable up
setInterval(() => {
  observable.count++;
}, 1000);

// subscribe to the observable to update the DOM
const counter = document.getElementById("counter");
const off = observable.on((count) => {
  counter.innerHTML = `current count: ${observable.count}`;
});

// unsubscribe on clicking the stop button
const stop = document.getElementById("stop");
stop.addEventListener("click", () => {
  off();
});

Additionally, all observables have a [merge] method with which you can merge one observable into another (so that the target has all properties of the the two observables and will fire onchange eventy when any of them change on either observable):

run on stackblitz

const observable1 = createObservable({
  count1: 0,
});
const observable2 = createObservable({
  count2: 0,
});
const merged = createObservable({});

merged.merge(observable1);
merged.merge(observable2);

setInterval(() => {
  observable1.count1++;
}, 1000);
setInterval(() => {
  observable2.count2 += 10;
}, 3000);
const counter = document.getElementById("counter");
merged.on(() => {
  counter.innerHTML = `count1 + count2: ${merged.count1 + merged.count2}`;
});

Rendering views and change detection

alter* components are generator functions that yield views:

function* HelloWorld() {
  yield html`<div>hello world</div>`;
}

A component may yield different results:

function* HelloWorld() {
  yield html`<div>Hello world!</div>`;
  yield html`<div>How are you?</div>`;
}

Note: The above would only ever yield the second view if a re-render was triggered from the outside (by the parent component re-rendering).

In order to re-render based on observables changing (setting state), you can use the [withObservables] helper. It will immediatly yield the view passed as first argument and resume rendering whenever any of the passed observables have changed.

run on stackblitz

const ObservableExample = createPreactComponent(function* () {
  const observable = createObservable({ askQuestion: false });
  setTimeout(() => {
    // yield the second view after 2 seconds:
    observable.false = true;
  }, 2000);
  yield withObservables(html`<div>Hello world!</div>`, observable);
  yield html`<div>How are you?</div>`;
});

The yield will also return the index of the observable that changed first:

const ObservableExample2 = createPreactComponent(function* () {
  const observables = [
    createObservable({ changed: false }),
    createObservable({ changed: false }),
  ];

  // ... insert code that would change either of the above two observable

  const changedIndex = yield withObservables(
    html`<div>Waiting for change!</div>`,
    ...observables
  );
  yield html`<div>observable #${changedIndex} changed!</div>`;
});

Async views

alter* adds first class support for async components and workflows:

run on stackblitz

const AsyncExample = createPreactComponent(async function* () {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  yield html`<div>promise resolved</div>`;
});

The above component will only render after the promise resolved.

Obviously, you might want to show a loading indicator while a component is waiting. That's where the [withPromise] helper shines. Like [withObservables] it will immediatly render the passed view and trigger a re-render once the promise has resolved! Additionally, yield will return the value from the promise!

run on stackblitz

const WithPromiseExample = createPreactComponent(async function* () {
  function fetchCurrentDate() {
    return new Promise((resolve) =>
      setTimeout(() => resolve(new Date().toDateString()), 2000)
    );
  }

  const date = yield withPromise(
    html` <div>...loading current date</div> `,
    fetchCurrentDate()
  );

  yield html` <div>current date: ${date}</div> `;
});

Hooks

alter* provides a few lifecycle hooks which work similiar to (p)react hooks with the major difference that they do not need to be called on every render (they act more akin to lifecycle event subscriptions).

[$layoutEffect] and [$sideEffect] are very similiar to useLayoutEffect and useEffect:

run on stackblitz

const EffectsExample = createPreactComponent(function* () {
  const observable = createObservable({ count: 0 });

  // $layoutEffect works much like useLayoutEffect
  // it runs synchronously after rendering
  $layoutEffect(
    () => {
      // this is the side effect, which creates an interval to count the observable up
      const intervalId = setInterval(() => {
        observable.count++;
      }, 1000);
      return () => {
        // we return a cleanup function to clear the interval when the component dismounts
        clearInterval(intervalId);
      };
      // the second argument is a function that should return a dependency array
      // only when a dependency changes will $layoutEffect trigger
      // an empty array means: run once (and cleanup on dismount)
    },
    () => []
  );

  // Also update the document title when count changes
  // side effects run asynchronously to renders
  $sideEffect(
    () => {
      document.title = observable.count;
    },
    () => [observable.count]
  );

  while (true) {
    yield withObservables(
      html` <div>count: ${observable.count}</div> `,
      observable
    );
  }
});

Additionally, the [$onRender] hooks is guaranteed to run on every render (of the underlying framework). So if you want to use (p)react custom hooks in your alter* component, this is where to put them:

run on stackblitz

const OnRenderExample = createPreactComponent(function* () {
  const observable = createObservable({ count: 0 });

  $layoutEffect(
    () => {
      const intervalId = setInterval(() => {
        observable.count++;
      }, 1000);
      return () => {
        clearInterval(intervalId);
      };
    },
    () => []
  );

  $onRender(() => {
    // you may use any (p)react hook here!
    useEffect(() => {
      document.title = observable.count;
    }, observable.count);
  });

  while (true) {
    yield withObservables(
      html` <div>count: ${observable.count}</div> `,
      observable
    );
  }
});

Creating integrations

[createIntegration] is the main API method for adding generator based component factories on top of a given framework. Here's how you would arrive at [createPreactComponent] using it:

import { createIntegration } from "alterisk";
import { useState, useEffect, useLayoutEffect } from "htm/preact";

export const {
  createComponent: createPreactComponent,
  layoutEffect,
  sideEffect,
} = createIntegration((api) => {
  // createIntegration expects you to return a valid component definition for the given framework
  // in this case, it's a function component
  // it might also be a class component
  const [init, render, sideEffect, layoutEffect, unmount] = api;
  return (props) => {
    const reRender = useState(0)[1];
    const id = useState(() => {
      // api.init should be called during initialization of a new component instance
      // it will return an id, which is a Symbol meant to identify the new instance
      return init(
        {
          // return a function that triggers a re-render for alterisk to use
          reRender: () => reRender((i) => i + 1),
        },
        props
      );
    })[0];

    // api.sideEffect (asyncronous) and api.layoutEffect (syncronours) should be called after each render
    useEffect(() => {
      sideEffect(id);
    });
    useLayoutEffect(() => {
      layoutEffect(id);
    });

    // call unmount on component unmount for cleanup purposes
    useEffect(
      () => () => {
        unmount(id);
      },
      []
    );

    return render(id, props); // call this for every re-render and pass props
  };
});

What's next?

Neither the current implementation nor the API are stable so I'd like some feedback via github issues :) Some things that are planned:

  • Typescript types (the library itself is written as es6 modules but I will provide a .d.ts file eventually)
  • A ready-to-use web component integration
  • Tests!