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

@cripty2001/whispr

v0.4.1

Published

A minimal, observable-based state engine with safe-by-default cloning, reactive computed values, and lifecycle-aware observables.

Downloads

387

Readme

Whispr

A tiny observable state manager for TypeScript.

Whispr helps you build reactive state using plain values—arrays, objects, numbers, anything. It’s lightweight, flexible, and designed for long-lived applications where memory safety matters.

Observables in Whispr automatically clean themselves up: derived values are passively tracked and disconnected when no longer used, avoiding leaks without the boilerplate of manual unsubscriptions.

No decorators, no UI frameworks, no globals — just a simple core that’s easy to reason about and safe to embed anywhere.

Features

  • 🧼 Zero dependencies – Works out of the box, no build tools or bundlers required.
  • 🧹 Automatic cleanup – Derived observables unsubscribe themselves when unused.
  • 🧠 Computed values – Create reactive state from other observables with Whispr.from.
  • 💓 Liveness tracking – Get notified when your observable is no longer referenced and clean up resources using the onDie callback.
  • 💤 .wait() and .load() – React to async availability of state without extra libraries or polling.
  • ✅ Written in TypeScript – Fully typed, safe, and ergonomic.

Whispr is small on purpose. It won’t manage your app. But it will whisper when your data changes, and stay out of the way when they doesn’t.

📦 Installation

npm install @cripty2001/whispr

🚀 Quick Start

import { Whispr } from "@cripty2001/whispr";

// Create a Whispr counter
const [counter, setCounter] = Whispr.create(
  0, // Initial Value
  () => {
    // (optional) onDie callback
    console.log("Counter is dead 😢");
  },
);

// Subscribe to changes
const unsubscribe = counter.subscribe((value) => {
  console.log("Counter is now", value);
});

// Update the Whispr value
const ok = setCounter(5);
if (!ok) {
  console.log("Counter is dead 😢");
}

// Access the latest value directly
console.log(counter.value);

// Unsubscribe from changes
unsubscribe();

// Create a derived Whispr
const doubled = Whispr.from({ value: counter }, ({ value }) => value * 2);
doubled.subscribe((val) => {
  console.log("Doubled:", val);
});

ℹ️ Once an observable is "dead", it will not be revived. If setCounter() returns false or onDie() is triggered, please clean up or stop updating. Whispr handles gracefully updates on dead items, but it is still a waste of resources.

🧼 Automatic Cleanup (Reactive Magic)

Whispr automatically tracks the lifecycle of each observable. When the returned data is no longer strongly referenced (i.e. it's orphaned), the optional onDie callback is triggered—giving you a clean opportunity to stop ongoing tasks like:

  • Unsubscribing from WebSockets
  • Stopping intervals or timeouts
  • Disconnecting listeners or tearing down resources

This cleanup logic is automatically propagated through Whispr.from chains as well—meaning derived observables clean up when all of their sources are gone. You don’t need to manually manage chains or subscriptions.

Just declare what needs to happen on cleanup, and let Whispr take care of the dirty work.

🔔 (Asyncronous) Listener Behavior

Whispr observables support asynchronous reactive subscriptions using .subscribe(callback, immediate = true).

At first glance, this might seem simple, but reactive flows have subtle tradeoffs. The way listeners are fired, how errors are handled, and when (or whether) updates are awaited all affect how predictable your app is, especially as it grows.

Here are some key properties and design choices behind Whispr’s listener model, and how they may affect your expectations:

✅ Synchronous updates

When set() is called, all listeners of that Whispr are fired synchronously, during the same update tick.

const [counter, setCounter] = Whispr.create(0);
counter.subscribe((val) => {
  console.log("Received value:", val);
});
setCounter((prev) => prev + 1);
// Listener is fired *immediately* here

This means the state is guaranteed to be consistent across all listeners and reads. Also, if the listener is syncronous, there are no race conditions or async propagation delays.

🔁 Fire-and-forget

Whispr does not wait for listeners to complete. If a listener is async, it’s still invoked synchronously and then left to run in the background:

counter.subscribe(async (val) => {
  await delay(1000);
  console.log("This ran later:", val);
});
setCounter((prev) => prev + 1);
console.log("next"); // This logs immediately. The async listener finishes later.

This is intentional: the component or logic performing the .set() operation does not need to know or wait for all listeners to finish.

🛑 STOP mechanism

Listeners (sync or async) can return "STOP" to unsubscribe themselves automatically.

const unsub = counter.subscribe((val) => {
  if (val > 3) return "STOP";
});

This avoids manual unsubscribe logic in many simple cases.

🧯 Error isolation

All listener callbacks are wrapped in try-catch. If a listener throws an error, Whispr catches it and logs it to the console. The subscription is kept active, though.

counter.subscribe((val) => {
  throw new Error("Oops!");
});

This prevents one faulty listener from disrupting the others or crashing the observable logic.

⚡ Immediate invocation

By default, .subscribe() fires the callback immediately with the current value:

const unsub = counter.subscribe((val) => {
  console.log("Initial value:", val); // immediately logs current value
});

This behavior can be turned off by passing false as the second argument:

counter.subscribe((val) => {
  console.log("Only future updates");
}, false);

⚠️ Note: Even immediate listeners are fire-and-forget. If the callback is async, there's no guarantee it completes before the .subscribe() call returns.

📌 Tip: For current value, use .value

If you just need the latest value, use .value:

const now = counter.value;

Do not subscribe unless you need to react to future changes. This avoids bugs where your async listener may not fire exactly when you think it will.

🧪 Listener Debug Checklist

If your listener isn't working as expected, here's a quick list of things to check before you panic and rewrite your app at 2am:

✅ Did you actually subscribe?

Common mistake: forgetting to call .subscribe()

  // ❌ Nothing happens here
counter.subscribe;

// ✅ You need to call it!
counter.subscribe((val) => { ... });

⚡ Did you mean to skip the initial value?

By default, subscribe() fires the listener immediately. You can disable that:

  counter.subscribe((val) => { ... }, false); // skips first fire

🔁 Is your listener async?

Async listeners don’t block the update cycle, and Whispr won’t wait for them:

counter.subscribe(async (val) => {
  await delay(500);
  console.log("This runs later");
});

→ If you need the current value immediately, use .value.

🧯 Did your listener throw an error?

If it crashed, you’ll see a warning in the console. The error is caught and the listener is kept alive. If you want to unsubsribe a listener after an error, just wrap it into a try catch block and return "STOP" from the catch

counter.subscribe((val) => {
  throw new Error("oops");
});
// Logs error, doesn't stop other listeners

🛑 Did it self-unsubscribe?

If your listener returns "STOP", it won’t be called again. That’s on purpose:

counter.subscribe((val) => {
  if (val > 10) return "STOP";
});

Check your conditions.

💭 Still not sure?

  • Confirm .value has the data you expect
  • Confirm your update logic is actually mutating the value
  • Try adding a debug listener that logs every update to see if things are working upstream
counter.subscribe((val) => console.log("DEBUG:", val));

⚠️ Async Updates? Handle With Care

It might seem tempting to support async update functions like this:

async function update(cb: (curr: T) => Promise<T>);

But here's the issue: what is the curr value in this case?

  • The value at the time update() was called?
  • The value at the time your async function starts executing?
  • Or worse, at the time it resolves?

In an async environment, update interleaving becomes inevitable. What seems like a harmless API leads to race conditions, overwrite bugs, and subtle inconsistencies that are nearly impossible to track in production.

Any solution here would be based on assumptions about developer intent—and assumptions don't scale.

✅ Whispr’s Design: Simple, Predictable, Safe

Whispr intentionally does not offer an async update method.

Instead, it gives you:

  • A safe, read-only .value accessor (please note that the value is NOT cloned, so it is NOT safe to directly mutate it. Manually clone it if required)
  • A pure, synchronous update(cb) setter

This mirrors the simplicity and reliability of React’s useState, ensuring you always work with predictable, up-to-date values, and never mutate data by mistake.

🌀 [Stay Tuned] For Async and Streaming Flows: Use (Fluctu - Coming Soon)

Need to manage async requests, intermediate results, or streaming data?

Enter (@cripty2001/fluctu - Coming Soon): a powerful async layer built on top of Whispr.

Fluctu uses Whispr under the hood for its reactivity core, and provides a flexible async interface designed to fit every data flow pattern—not just the common ones.

It includes convenient built-in modes for popular use cases:

| Mode | When it Publishes | Best For | | --------------------- | -------------------------------- | ----------------------------------------- | | Debounced Mode | Only if it's still the latest | Stable UI, no flicker, final answers only | | Async Result Mode | Always, unless newer result won | Intermediate results are helpful | | Streaming Mode | Anytime (if no newer result won) | Real-time, chunked, or partial data flows |

But this is just the beginning.

Like Whispr, Fluctu gives you a generic low-level interface—the building blocks to design any async behavior you want.

Whether you're implementing a data loader, a streaming API handler, or a debounce/cancel logic across changing parameters, Fluctu lets you express your intent without boilerplate.

And since it’s all powered by Whispr, your async flows remain fully observable, reactive, and memory-aware.

🎓 Pro Tips

Wait

Wait until an observable matches a specific condition:

const [user, setUser] = Whispr.create<User | null>(null);

fetch("/user")
  .then((data) => data.json())
  .then((data) => setUser(data));

await user.wait((u) => u !== null);

// This will implicitly pause the execution until the fetch completed successfully

Load

You are just waiting for a non-null and non-undefined value? Use load()

const [user, setUser] = Whispr.create<User | null>(null);

fetch("/user")
  .then((data) => data.json())
  .then((data) => setUser(data));

await user.load();

// This will implicitly pause the execution until the fetch completed successfully

Derived Whispr

Easily build merged Whisprs with Whispr.from, having it kept in sync automatically

// users is a list of users id
// profiles is a map of data indexed by user id

const merged = Whispr.from(
  {
    users: users,
    profiles: profiles,
  },
  ({ users, profiles }) => {
    return users.map((item) => ({
      id: item,
      profile: profiles[id],
    }));
  },
);

✨ Merged is kept in sync with both users and profiles, and, when it goes out of scope, it is automatically unsubscribed from both to save resources

Shorthand

If the derived Whispr is equal to its input, you can use the Whispr.consolidate instead. This is particularly useful to merge a series of Whispr into a single one, to consolidate reactivity and improve developer experience.

const [userId] = Whispr.create('user');
const profile = loadUserProfile(userId) // Returns Whispr(UserProfile | null) - null while loading

const merged_from = Whispr.from(
  {
    id: userId,
    profile: profile
  },
  ({id, profile} => ({
    id,
    profile
  }))
)

const merged_consolidate = Whispr.consolidate({id, profile})

// merged_from and merged_consolidate are practically the same

If you are just transforming a single Whispr, use w.transform

const user: Whispr<User>;

const uid = user.transform((u) => u.id);
const uid_from = Whispr.from({ user: user }, ({ user }) => {
  return user.id;
});

// uid and uid_from are practically the same

Liveness Notifications

Easily bind cleanup to object liveness

// Create a Whispr observable for the latest message
const [message, setMessage] = Whispr.create<string | null>(null, () => {
  ws.close();
});

// Open a websocket
const ws = new WebSocket("wss://example.org");

// Listen for messages
ws.addEventListener("message", (event) => {
  set(event.data);
});

Due to the unsubscribe callback, the wss is automatically closed when message dies. The good thing? This can be applied to anything!

🛡️ Type Safety

All the Whispr library is fully typed.

const a = Whispr.create<
  T // Type of a.value
>()

const f = Whispr.from<
  I // Type of the input, as a map,
  O // Type of the output
>()

🧠 API Reference

🔄 Whispr<T>

A reactive observable container with safe updates, subscriptions, and lifecycle management.

Whispr.create<T>(initial: T, onDie?: () => void): [Whispr<T>, WhisprMutations<T> ]

Creates a new observable instance. When the observable is no longer referenced, onDie will be called.

const [user, setUser] = Whispr.create({ name: "Alice" });

value: T

Returns the current observable value. The value is NOT cloned, so it is NOT safe to directly mutate it.

subscribe(cb: (data: T) => void | "STOP", immediate?: boolean): () => void

Subscribes to the observable. The callback is called on every change. Return "STOP" to unsubscribe automatically. Please note that data is NOT cloned, so it is NOT safe to directly mutate it.

wait(cb: (data: T) => R | null | undefined): Promise<R>

Waits for the first non-null result from cb(data). Automatically unsubscribes after resolution.

load(): Promise<NonNullable<T>>

Waits until the observable emits a defined, non-null value. Equivalent to wait(data => data).

🧩 Whispr.from(...)

Creates a computed observable from multiple source observables.

🔧 License

MIT

👋 Author

Built with care by Fabio Mauri (cripty2001[at]outlook[dot]com).

Contributions and issues welcome (especially on tests)!