{"id":47940846,"url":"https://github.com/kettanaito/async-history-stack","last_synced_at":"2026-04-05T09:01:17.731Z","repository":{"id":348039347,"uuid":"1195249131","full_name":"kettanaito/async-history-stack","owner":"kettanaito","description":"Arbitrary change history management in JavaScript.","archived":false,"fork":false,"pushed_at":"2026-03-30T16:02:33.000Z","size":44,"stargazers_count":155,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-04-04T08:29:13.950Z","etag":null,"topics":["history","redo","revert","stack","undo"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","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/kettanaito.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE.md","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,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":"kettanaito"}},"created_at":"2026-03-29T12:38:08.000Z","updated_at":"2026-04-04T05:47:35.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/kettanaito/async-history-stack","commit_stats":null,"previous_names":["kettanaito/async-history-stack"],"tags_count":5,"template":false,"template_full_name":null,"purl":"pkg:github/kettanaito/async-history-stack","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kettanaito%2Fasync-history-stack","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kettanaito%2Fasync-history-stack/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kettanaito%2Fasync-history-stack/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kettanaito%2Fasync-history-stack/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/kettanaito","download_url":"https://codeload.github.com/kettanaito/async-history-stack/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kettanaito%2Fasync-history-stack/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31430011,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-05T08:13:15.228Z","status":"ssl_error","status_checked_at":"2026-04-05T08:13:11.839Z","response_time":75,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"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":["history","redo","revert","stack","undo"],"created_at":"2026-04-04T08:01:49.380Z","updated_at":"2026-04-05T09:01:17.692Z","avatar_url":"https://github.com/kettanaito.png","language":"TypeScript","funding_links":["https://github.com/sponsors/kettanaito"],"categories":[],"sub_categories":[],"readme":"# Async History Stack\n\n## Motivation\n\nIt seems that most undo/redo tools are coupled with state management or rich editor libraries. It's natural to assume something like a change history should live next to your state. That assumption works great when all your state _lives in one place_ and all the side effects related to state changes are _coupled with those changes_. In practice, that's not always the case. Third-party libraries can introduce their own state (think a rich editor) and it's not always best to try to unify it. Sometimes state works best being partial, such as the list of uploaded images only containing references to those images while the files themselves live in the file system, and changes to the state alone are insufficient to describe what actually happens when uploading a file.\n\nHere, an argument can be made that a state change function must include any related side effects within it, which is also not always viable. Not every state change is triggered by the client (e.g. the main process already uploaded an image and only sends the reference to the client) and not all side effects are directly related to the state change (e.g. you might want to trigger a navigation, or any other UI transition, when undoing certain changes).\n\nThat's only scratching the architectural surface. There are a ton of practical aspects to traversing the change history, such as asynchronicity, cancellation, merging, batching, that are incomplete or entirely missing in the tools I could find. So I built my own.\n\n## Getting started\n\n```sh\nnpm i async-history-stack\n```\n\nThis library works by introducing a singleton that tracks the change history and allows its traversal.\n\n```ts\n// src/history.ts\nimport { HistoryStack } from 'async-history-stack'\n\nexport const historyStack = new HistoryStack({\n  limit: 100\n})\n```\n\nYou register changes by pushing them to the `historyStack`. Every change is described as the _apply function_ that returns the _revert function_. Upon push, the apply function is invoked immediately for convenience. When the change is undone, the revert function is called and the two _switch places_ to reflect the traversal order (undoing a revert is the same as applying the change).\n\n```ts\nawait historyStack.push(() =\u003e {\n  applyChanges()\n\n  return () =\u003e {\n    revertChanges()\n  }\n})\n```\n\nHere's an example of using the history stack to delete an image from the image detail route:\n\n```tsx\nimport { historyStack } from './history'\n\nexport async function deleteImage(imageId: string) {\n  await historyStack.push(async () =\u003e {\n    await router.navigate({ to: '/images' })\n    \n    // Signal the main process to delete the image from disk.\n    await rpc.deleteImage(imageId)\n    \n    // Delete the image record from the state.\n    deleteImageRef(imageId)\n\n    return async () =\u003e {\n      // Undo the image deletion (i.e. re-upload the image).\n      const ref = await rpc.uploadImage(imageId)\n      addImageRef(ref)\n      \n      // Go back to the relevant image detail page.\n      await router.navigate({ to: '/images/$imageId', params: { imageId } })\n    }\n  })\n}\n```\n\n## API\n\n### `new HistoryStack(options)`\n\n- `options`:\n  - `limit`, `number`, the maximum number of entries in this stack;\n  - `autoMergeWithin`, `number` (default: `0`), automatically merge history entries pushed within the given window (ms). Handy when changes trigger often (e.g. typing into a rich text editor).\n\n#### `.push()`\n\nRegister a new history entry. Accepts the apply function that returns the revert function. Automatically invokes the apply function for convenience.\n\n```ts\nawait historyStack.push(async ({ signal }) =\u003e {\n  return async ({ signal }) =\u003e {}\n})\n```\n\nBoth the apply and revert functions can be synchronous and asynchronous. Both functions also accept a `signal` that will be aborted when a change transition is cancelled (e.g. when reverting the change while apply is in progress). Utilize this by providing the `signal` to the APIs that natively support it, like `fetch` or web streams, and listen to its `signal.aborted` to abort your custom logic otherwise.\n\n#### `.merge()`\n\nMerge multiple history entries into one. Handy for expressing complex changes that must be applied/reverted as a single entry.\n\n```ts\nawait historyStack.push(\n  historyStack.merge(\n    async () =\u003e {\n      await action()\n      return async () =\u003e await revertAction()\n    },\n    () =\u003e {\n      sideEffect()\n      return () =\u003e revertSideEffect()\n    },\n  )\n)\n```\n\n#### `.undo()`\n\nUndo the latest change. Returns `true` if the change has been undone, `false` otherwise.\n\n#### `.redo()`\n\nRedo the latest previous change. Returns `true` if the change has been redone, `false` otherwise.\n\n#### `.clear()`\n\nClear the history stack. Accepts an optional boolean argument to abort any in-flight changes.\n\n```ts\nhistoryStack.clear()\n\n// Clear the stack and abort any pending changes.\nhistoryStack.clear(true)\n```\n\n#### `.size`\n\nTotal count of all history entries in this stack.\n\n#### `.timestamp`\n\nTimestamp of the latest completed change. Handy for deriving state like `isDirty`.\n\n## Recipes\n\n### Revert-friendly state transitions\n\nConsider returning a revert function from your state change functions:\n\n```ts\n// src/stores/images.ts\nexport function addImageRef(imageRef) {\n  imagesStore.setState((refs) =\u003e {\n    refs.push(imageRef)\n  })\n  \n  return () =\u003e {\n    imagesStore.setState((refs) =\u003e {\n      refs.splice(refs.indexOf(imageRef), 1)\n    })\n  }\n}\n```\n\nThis way, apply/revert are collocated under a single transition and don't have to be described separately.\n\n```ts\nawait historyStack.push(() =\u003e addImageRef(ref))\n\n// This works well with merged entries, too.\nawait historyStack.push(\n  historyStack.merge(\n    async () =\u003e await rpc.uploadImage(imageId),\n    () =\u003e addImageRef(ref),\n  )\n)\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkettanaito%2Fasync-history-stack","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fkettanaito%2Fasync-history-stack","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkettanaito%2Fasync-history-stack/lists"}