{"id":19465424,"url":"https://github.com/michael-klein/alterisk","last_synced_at":"2026-02-25T19:07:17.338Z","repository":{"id":79440539,"uuid":"258009162","full_name":"michael-klein/alterisk","owner":"michael-klein","description":"A generator driven component api for (p)react and more!","archived":false,"fork":false,"pushed_at":"2020-05-04T19:54:51.000Z","size":169,"stargazers_count":9,"open_issues_count":0,"forks_count":0,"subscribers_count":5,"default_branch":"master","last_synced_at":"2025-04-25T09:44:18.630Z","etag":null,"topics":["async","component","generators","preact","react","ui"],"latest_commit_sha":null,"homepage":"","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/michael-klein.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2020-04-22T20:11:41.000Z","updated_at":"2021-03-12T16:37:58.000Z","dependencies_parsed_at":null,"dependency_job_id":"4bee0fa3-47d7-4bd9-afcf-2e551d2b74b0","html_url":"https://github.com/michael-klein/alterisk","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/michael-klein/alterisk","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/michael-klein%2Falterisk","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/michael-klein%2Falterisk/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/michael-klein%2Falterisk/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/michael-klein%2Falterisk/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/michael-klein","download_url":"https://codeload.github.com/michael-klein/alterisk/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/michael-klein%2Falterisk/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":269833250,"owners_count":24482413,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-08-11T02:00:10.019Z","response_time":75,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["async","component","generators","preact","react","ui"],"created_at":"2024-11-10T18:20:01.000Z","updated_at":"2026-02-25T19:07:12.304Z","avatar_url":"https://github.com/michael-klein.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003ch1 align=\"center\"\u003e\n  alter* (alterisk) \u003ca href=\"https://www.npmjs.org/package/htm\"\u003e\u003cimg src=\"https://img.shields.io/npm/v/alterisk.svg?style=flat\" alt=\"npm\"\u003e\u003c/a\u003e\n\u003c/h1\u003e\n\u003cdiv align=\"center\"\u003e\nA generator driven component api for (p)react and more!\n\u003c/div\u003e\n\u003cbr /\u003e\n\u003cbr /\u003e\n\n## What is this about?\n\nalter\\* is an attempt to provide an (async) generator driven alternative component model for preact, react and more (you can create your own integration).\n\nThe 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](https://github.com/michael-klein)) and the general interest of the community in [Crank.js](https://crank.js.org/) finally made me give it a try.\n\nThis project is mostly experimental at this stage and I'm publishing it early to gather some feedback.\n\n## Okay, but how does it look?\n\nThe 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):\n\n[run on stackblitz](https://stackblitz.com/edit/fake-signup-form-2)\n\n```javascript\nimport {\n  createPreactComponent,\n  render,\n  html,\n  h,\n  createObservable,\n  withObservables,\n  withPromise,\n  $layoutEffect,\n} from \"alterisk/preact\";\nimport { css } from \"goober\";\nimport { Card, Center, Form } from \"./misc\";\n\n// this is an example work flow using alter* with a fake signup form\nconst Signup = createPreactComponent(function* () {\n  // an observable is a proxified objects that emits change events when any of it's (deep) properties changes\n  // you could submit to these with formData.on(value =\u003e console.log(value))\n  const formData = createObservable({\n    email: \"\",\n    password: \"\",\n    avatar: false,\n    step: 0, // identifies what step in the signup process we are in\n    // step 0: enter email + password\n    // step 1: upload avatar\n    // step 3: we submit the form\n  });\n\n  // this is similiar to (p)reacts useLayoutEffect hook\n  // it runs whenever the result of the second function returns a change in the dependency array\n  // you may also return a cleanup function from the effect, just like in useLayoutEffect\n  // careful: you only need to execture $layoutEffect once and not on every render, so don't put it inside the while loop below\n  $layoutEffect(\n    () =\u003e {\n      document.title = `Step: ${formData.step}`;\n    },\n    () =\u003e [formData.step]\n  );\n\n  // while the user still needs to enter data, we remain in steps 0 and 1\n  while (formData.step \u003c 2) {\n    switch (formData.step) {\n      case 0:\n        // withObservables renders the passed view and then waits for the passed observable to change to re-render\n        yield withObservables(renderStep1(), formData);\n        break;\n      case 1:\n        yield withObservables(renderStep2(), formData);\n        break;\n    }\n  }\n  // after step 1 is done, we break out of the loop and (fake) submit the form\n  // withPromise triggers a re-render when the passed promise resolves\n  // until then, we render a loading spinner\n  const success = yield withPromise(renderLoadingSpinner(), submitSignUpForm());\n\n  // handle the response\n  if (success) {\n    // successfully signed up, render a success message!\n    yield html`\n      \u003c${Center}\u003e\n        \u003c${Card}\u003e\n          (fake) sign-up successful!\n        \u003c/${Card}\u003e\n      \u003c/${Center}\u003e`;\n  } else {\n    // do something else (we're skipping this part)\n  }\n\n  function renderStep1() {\n    const canSubmit = formData.email.length \u003e 3 \u0026\u0026 formData.password.length \u003e 3;\n    return html`\n        \u003c${Center}\u003e\n          \u003c${Card}\u003e\n            \u003cdiv class=\"instructions\"\u003ePlease sign up here via our (fake) form:\u003c/div\u003e\n            \u003c${Form} autocomplete=\"off\"\u003e\n              \u003cinput \n                type=\"text\" \n                placeholder=\"email\" \n                value=${formData.email} \n                oninput=${(e) =\u003e (formData.email = e.target.value)} /\u003e\n              \u003cinput class=\"avatar\" type=\"password\" \n                placeholder=\"password\" \n                value=${formData.password}   \n                oninput=${(e) =\u003e (formData.password = e.target.value)} /\u003e\n              \u003cdiv class=\"submit\"\u003e\n                \u003cbutton \n                  disabled=${!canSubmit} \n                  onclick=${(e) =\u003e formData.step++}\u003enext: select an avatar\n                \u003c/button\u003e\n              \u003c/div\u003e\n            \u003c/${Form}\u003e\n          \u003c/${Card}\u003e\n        \u003c/${Center}\u003e`;\n  }\n\n  function renderStep2() {\n    const canSubmit = formData.avatar;\n    return html`\n        \u003c${Center}\u003e\n          \u003c${Card}\u003e\n            \u003cdiv class=\"instructions\"\u003ePlease upload an avatar picture:\n              \u003cdiv class=${css`\n                font-size: 10px;\n              `}\u003e\n                (we're not acctually uploading anything)\n              \u003c/div\u003e\n            \u003c/div\u003e\n            \u003c${Form} autocomplete=\"off\"\u003e\n            \u003cinput type=\"file\" \n              name=\"avatar\"\n              accept=\"image/png, image/jpeg\" \n              onchange=${(e) =\u003e (formData.avatar = true)}\n              /\u003e\n              \u003cdiv class=\"submit\"\u003e        \n                \u003cbutton \n                  class=\"previous\"\n                  onclick=${(e) =\u003e formData.step--}\u003eback\n                \u003c/button\u003e\n                \u003cbutton \n                  disabled=${!canSubmit} \n                  onclick=${(e) =\u003e formData.step++}\u003esubmit\n                \u003c/button\u003e\n              \u003c/div\u003e\n            \u003c/${Form}\u003e\n          \u003c/${Card}\u003e\n        \u003c/${Center}\u003e`;\n  }\n\n  function renderLoadingSpinner() {\n    return html`\n        \u003c${Center}\u003e\n          \u003c${Card}\u003e\n            \u003cdiv class=\"lds-ellipsis\"\u003e\u003cdiv\u003e\u003c/div\u003e\u003cdiv\u003e\u003c/div\u003e\u003cdiv\u003e\u003c/div\u003e\u003cdiv\u003e\u003c/div\u003e\u003c/div\u003e\n          \u003c/${Card}\u003e\n        \u003c/${Center}\u003e`;\n  }\n\n  function submitSignUpForm() {\n    return new Promise((resolve) =\u003e setTimeout(() =\u003e resolve(true), 2000));\n  }\n});\n\nrender(html` \u003c${Signup} /\u003e `, document.body);\n```\n\n## API\n\nThe API added on top of a framework by aster\\* is fairly simple. I will explain it using the provided preact integration.\n\n### Observables\n\nObservables 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:\n\n[run on stackblitz](https://stackblitz.com/edit/observables-example1)\n\n```javascript\nimport { createObservable } from \"alterisk\";\n\n// create a new observable\nconst observable = createObservable({\n  count: 0,\n});\n// count observable up\nsetInterval(() =\u003e {\n  observable.count++;\n}, 1000);\n\n// subscribe to the observable to update the DOM\nconst counter = document.getElementById(\"counter\");\nconst off = observable.on((count) =\u003e {\n  counter.innerHTML = `current count: ${observable.count}`;\n});\n\n// unsubscribe on clicking the stop button\nconst stop = document.getElementById(\"stop\");\nstop.addEventListener(\"click\", () =\u003e {\n  off();\n});\n```\n\nAdditionally, you can use the [mergeObservables] method with which you can merge one observable into another (so that the result has all properties of the the two observables and will fire onchange eventy when any of them change on either observable):\n\n[run on stackblitz](https://stackblitz.com/edit/observables-example2)\n\n```javascript\nimport { createObservable, mergeObservables } from \"alterisk\";\n\nconst observable1 = createObservable({\n  count1: 0\n});\nconst observable2 = createObservable({\n  count2: 0\n});\nconst merged = mergeObservables(observable1, observable2);\n\nsetInterval(() =\u003e {\n  observable1.count1++;\n}, 1000);\nsetInterval(() =\u003e {\n  observable2.count2+=10;\n}, 3000);\nconst counter = document.getElementById(\"counter\");\nmerged.on(() =\u003e {\n  counter.innerHTML = `count1 + count2: ${merged.count1 + merged.count2}`;\n});\n```\n\n### Rendering views and change detection\n\nalter\\* components are generator functions that yield views:\n\n```javascript\nfunction* HelloWorld() {\n  yield html`\u003cdiv\u003ehello world\u003c/div\u003e`;\n}\n```\n\nA component may yield different results:\n\n```javascript\nfunction* HelloWorld() {\n  yield html`\u003cdiv\u003eHello world!\u003c/div\u003e`;\n  yield html`\u003cdiv\u003eHow are you?\u003c/div\u003e`;\n}\n```\n\nNote: The above would only ever yield the second view if a re-render was triggered from the outside (by the parent component re-rendering).\n\nIn 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.\n\n[run on stackblitz](https://stackblitz.com/edit/withobservable-example)\n\n```javascript\nconst ObservableExample = createPreactComponent(function* () {\n  const observable = createObservable({ askQuestion: false });\n  setTimeout(() =\u003e {\n    // yield the second view after 2 seconds:\n    observable.false = true;\n  }, 2000);\n  yield withObservables(html`\u003cdiv\u003eHello world!\u003c/div\u003e`, observable);\n  yield html`\u003cdiv\u003eHow are you?\u003c/div\u003e`;\n});\n```\n\nThe yield will also return the index of the observable that changed first:\n\n```javascript\nconst ObservableExample2 = createPreactComponent(function* () {\n  const observables = [\n    createObservable({ changed: false }),\n    createObservable({ changed: false }),\n  ];\n\n  // ... insert code that would change either of the above two observable\n\n  const changedIndex = yield withObservables(\n    html`\u003cdiv\u003eWaiting for change!\u003c/div\u003e`,\n    ...observables\n  );\n  yield html`\u003cdiv\u003eobservable #${changedIndex} changed!\u003c/div\u003e`;\n});\n```\n\n### Async views\n\nalter\\* adds first class support for async components and workflows:\n\n[run on stackblitz](https://stackblitz.com/edit/async-component-example?file=index.js)\n\n```javascript\nconst AsyncExample = createPreactComponent(async function* () {\n  await new Promise((resolve) =\u003e setTimeout(resolve, 2000));\n  yield html`\u003cdiv\u003epromise resolved\u003c/div\u003e`;\n});\n```\n\nThe above component will only render after the promise resolved.\n\nObviously, 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!\n\n[run on stackblitz](https://stackblitz.com/edit/withpromise-example?file=index.js)\n\n```javascript\nconst WithPromiseExample = createPreactComponent(async function* () {\n  function fetchCurrentDate() {\n    return new Promise((resolve) =\u003e\n      setTimeout(() =\u003e resolve(new Date().toDateString()), 2000)\n    );\n  }\n\n  const date = yield withPromise(\n    html` \u003cdiv\u003e...loading current date\u003c/div\u003e `,\n    fetchCurrentDate()\n  );\n\n  yield html` \u003cdiv\u003ecurrent date: ${date}\u003c/div\u003e `;\n});\n```\n\n### Hooks\n\nalter\\* 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).\n\n[$layoutEffect] and [$sideEffect] are very similiar to useLayoutEffect and useEffect:\n\n[run on stackblitz](https://stackblitz.com/edit/effect-example?file=index.js)\n\n```javascript\nconst EffectsExample = createPreactComponent(function* () {\n  const observable = createObservable({ count: 0 });\n\n  // $layoutEffect works much like useLayoutEffect\n  // it runs synchronously after rendering\n  $layoutEffect(\n    () =\u003e {\n      // this is the side effect, which creates an interval to count the observable up\n      const intervalId = setInterval(() =\u003e {\n        observable.count++;\n      }, 1000);\n      return () =\u003e {\n        // we return a cleanup function to clear the interval when the component dismounts\n        clearInterval(intervalId);\n      };\n      // the second argument is a function that should return a dependency array\n      // only when a dependency changes will $layoutEffect trigger\n      // an empty array means: run once (and cleanup on dismount)\n    },\n    () =\u003e []\n  );\n\n  // Also update the document title when count changes\n  // side effects run asynchronously to renders\n  $sideEffect(\n    () =\u003e {\n      document.title = observable.count;\n    },\n    () =\u003e [observable.count]\n  );\n\n  while (true) {\n    yield withObservables(\n      html` \u003cdiv\u003ecount: ${observable.count}\u003c/div\u003e `,\n      observable\n    );\n  }\n});\n```\n\nAdditionally, 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:\n\n[run on stackblitz](https://stackblitz.com/edit/onrender-example?file=index.js)\n\n```javascript\nconst OnRenderExample = createPreactComponent(function* () {\n  const observable = createObservable({ count: 0 });\n\n  $layoutEffect(\n    () =\u003e {\n      const intervalId = setInterval(() =\u003e {\n        observable.count++;\n      }, 1000);\n      return () =\u003e {\n        clearInterval(intervalId);\n      };\n    },\n    () =\u003e []\n  );\n\n  $onRender(() =\u003e {\n    // you may use any (p)react hook here!\n    useEffect(() =\u003e {\n      document.title = observable.count;\n    }, [observable.count]);\n  });\n\n  while (true) {\n    yield withObservables(\n      html` \u003cdiv\u003ecount: ${observable.count}\u003c/div\u003e `,\n      observable\n    );\n  }\n});\n```\n\n### Creating integrations\n\n[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:\n\n```javascript\nimport { createIntegration } from \"alterisk\";\nimport { useState, useEffect, useLayoutEffect } from \"htm/preact\";\n\nexport const {\n  createComponent: createPreactComponent,\n  layoutEffect,\n  sideEffect,\n} = createIntegration((api) =\u003e {\n  // createIntegration expects you to return a valid component definition for the given framework\n  // in this case, it's a function component\n  // it might also be a class component\n  const [init, render, sideEffect, layoutEffect, unmount] = api;\n  return (props) =\u003e {\n    const reRender = useState(0)[1];\n    const id = useState(() =\u003e {\n      // api.init should be called during initialization of a new component instance\n      // it will return an id, which is a Symbol meant to identify the new instance\n      return init(\n        {\n          // return a function that triggers a re-render for alterisk to use\n          reRender: () =\u003e reRender((i) =\u003e i + 1),\n        },\n        props\n      );\n    })[0];\n\n    // api.sideEffect (asyncronous) and api.layoutEffect (syncronours) should be called after each render\n    useEffect(() =\u003e {\n      sideEffect(id);\n    });\n    useLayoutEffect(() =\u003e {\n      layoutEffect(id);\n    });\n\n    // call unmount on component unmount for cleanup purposes\n    useEffect(\n      () =\u003e () =\u003e {\n        unmount(id);\n      },\n      []\n    );\n\n    return render(id, props); // call this for every re-render and pass props\n  };\n});\n```\n\n## What's next?\n\nNeither the current implementation nor the API are stable so I'd like some feedback via github issues :)\nSome things that are planned:\n\n- Typescript types (the library itself is written as es6 modules but I will provide a .d.ts file eventually)\n- A ready-to-use web component integration\n- Tests!\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmichael-klein%2Falterisk","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmichael-klein%2Falterisk","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmichael-klein%2Falterisk/lists"}