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 🙏

© 2026 – Pkg Stats / Ryan Hefner

async-history-stack

v1.0.4

Published

Arbitrary change history management in JavaScript.

Downloads

536

Readme

Async History Stack

Motivation

It 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.

Here, 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).

That'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.

Getting started

npm i async-history-stack

This library works by introducing a singleton that tracks the change history and allows its traversal.

// src/history.ts
import { HistoryStack } from 'async-history-stack'

export const historyStack = new HistoryStack({
  limit: 100
})

You 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).

await historyStack.push(() => {
  applyChanges()

  return () => {
    revertChanges()
  }
})

Here's an example of using the history stack to delete an image from the image detail route:

import { historyStack } from './history'

export async function deleteImage(imageId: string) {
  await historyStack.push(async () => {
    await router.navigate({ to: '/images' })
    
    // Signal the main process to delete the image from disk.
    await rpc.deleteImage(imageId)
    
    // Delete the image record from the state.
    deleteImageRef(imageId)

    return async () => {
      // Undo the image deletion (i.e. re-upload the image).
      const ref = await rpc.uploadImage(imageId)
      addImageRef(ref)
      
      // Go back to the relevant image detail page.
      await router.navigate({ to: '/images/$imageId', params: { imageId } })
    }
  })
}

API

new HistoryStack(options)

  • options:
    • limit, number, the maximum number of entries in this stack;
    • 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).

.push()

Register a new history entry. Accepts the apply function that returns the revert function. Automatically invokes the apply function for convenience.

await historyStack.push(async ({ signal }) => {
  return async ({ signal }) => {}
})

Both 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.

.merge()

Merge multiple history entries into one. Handy for expressing complex changes that must be applied/reverted as a single entry.

await historyStack.push(
  historyStack.merge(
    async () => {
      await action()
      return async () => await revertAction()
    },
    () => {
      sideEffect()
      return () => revertSideEffect()
    },
  )
)

.undo()

Undo the latest change. Returns true if the change has been undone, false otherwise.

.redo()

Redo the latest previous change. Returns true if the change has been redone, false otherwise.

.clear()

Clear the history stack. Accepts an optional boolean argument to abort any in-flight changes.

historyStack.clear()

// Clear the stack and abort any pending changes.
historyStack.clear(true)

.size

Total count of all history entries in this stack.

.timestamp

Timestamp of the latest completed change. Handy for deriving state like isDirty.

Recipes

Revert-friendly state transitions

Consider returning a revert function from your state change functions:

// src/stores/images.ts
export function addImageRef(imageRef) {
  imagesStore.setState((refs) => {
    refs.push(imageRef)
  })
  
  return () => {
    imagesStore.setState((refs) => {
      refs.splice(refs.indexOf(imageRef), 1)
    })
  }
}

This way, apply/revert are collocated under a single transition and don't have to be described separately.

await historyStack.push(() => addImageRef(ref))

// This works well with merged entries, too.
await historyStack.push(
  historyStack.merge(
    async () => await rpc.uploadImage(imageId),
    () => addImageRef(ref),
  )
)