Polyrhythm
A 3Kb full-stack async effect management toolkit over RxJS. Uses a Pub-Sub paradigm to orchestrate Observables in Node, or the browser (ala Redux Saga). Exports: channel, listen, filter, trigger, after.
Install / Use
/learn @deanrad/PolyrhythmREADME
<a href="#badge"><img alt="code style: prettier" src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square"></a>
polyrhythm 🎵🎶
polyrhythm is a way to avoid async race conditions, particularly those that arise when building UIs, in JavaScript. It can replace Redux middleware like Redux Saga, and is a framework-free library that supercharges your timing and resource-management. And it's under 4Kb.
Its API is a synthesis of ideas from:
- 💜RxJS. Older than Promises, nearly as old as JQuery.
- 💜Redux-Observable, Redux Saga, Redux Thunk. Async.
- 💙Macromedia Flash. Mutliple timelines.
- 💙JQuery. #on and #trigger.
For use in a React context, polyrhythm-react exports all in this library, plus React hooks for interfacing with it.
Installation
npm install polyrhythm
What Is It?
polyrhythm is a TypeScript library that is a framework-independent coordinator of multiple streams of async using RxJS Observables.
The goal of polyrhythm is to be a centralized control of timing for sync or async operations in your app.
Because of it's pub-sub/event-bus design, your app remains inherently scalable because originators of events don't know about consumers, or vice versa. If a single subscriber errs out, neither the publisher nor other subscribers are affected. Your UI layer remains simple— its only job is to trigger/originate events. All the logic remains separated from the UI layer by the event bus. Testing of most of your app's effects can be done without going through your UI layer.
polyrhythm envisions a set of primitives can compose into beautiful Web Apps and User Experiences more simply and flexibly than current JavaScript tools allow for. All thie with a tiny bundle size, and an API that is delightfully simple.
Where Can I Use It?
The framework-independent primitives of polyrhythm can be used anywhere. It adds only 3Kb to your bundle, so it's worth a try. It is test-covered, provides types, is production-tested and performance-tested.
Declare Your Timing, Don't Code It
RxJS was written in 2010 to address the growing need for async management code in the world of AJAX. Yet in 2021, it can still be a large impact to the codebase to add async to a function declaration, or turn a function into a generator with function*() {}. That impact can 'hard-code' in unadaptable behaviors or latency. And relying on framework features (like the timing difference between useEffect and useLayoutEffect) can make code vulnerable to framework changes, and make it harder to test.
polyrhythm gives you 5 concurrency modes you can plug in trivially as configuration parameters, to get the full power of RxJS elegantly.
The listener option mode allows you to control the concurrency behavior of a listener declaratively, and is important for making polyrhythm so adaptable to desired timing outcomes. For an autocomplete or session timeout, the replace mode is appropriate. For other use cases, serial, parallel or ignore may be appropriate.
If async effects like AJAX were represented as sounds, this diagram shows how they might overlap/queue/cancel each other.
<a href="https://s3.amazonaws.com/www.deanius.com/ConcurModes2.png"><img height="400" src="https://s3.amazonaws.com/www.deanius.com/ConcurModes2.png"></a>
Being able to plug in a strategy ensures that the exact syntax of your code, and your timing information, are decoupled - the one is not expressed in terms of the other. This lets you write fewer lines, be more direct and declarative, and generally cut down on race conditions.
Not only do these 5 modes handle not only what you'd want to do with RxJS, but they handle anything your users would expect code to do when async process overlap! You have the ease to change behavior to satisfy your pickiest users, without rewriting code - you only have to update your tests to match!

Now let's dig into some examples.
Example 1: Auto-Complete Input (replace mode)
Based on the original example at LearnRxjs.io...
Set up an event handler to trigger search/start events from an onChange:
<input onChange={e => trigger('search/start', e.target.value)} />
Listen for the search/results event and update component or global state:
filter('search/results', ({ payload: results }) => {
setResults(results);
});
Respond to search/start events with an Observable, or Promise of the ajax request.
<<<<<<< HEAD
Assign the output to the search/results event, and specify your mode, and you're done and race-condition-free!
on(
'search/start',
({ payload }) => {
return fetch(URL + payload).then(res => res.json());
},
{
mode: 'replace',
trigger: { next: 'search/results' },
}
);
mode:replace does what switchMap does, but with readability foremost, and without requiring you to model your app as a chained Observable, or manage Subscription objects or call .subscribe() or .unsubscribe() explicitly.
Example 2: Ajax Cat Fetcher (multi-mode)
Based on an XState Example showing the value of separating out effects from components, and how to be React Concurrent Mode (Suspense-Mode) safe, in XState or Polyrhythm.
Try it out - play with it! Is the correct behavior to use serial mode to allow you to queue up cat fetches, or ignore to disable new cats while one is loading, as XState does? You choose! I find having these options easily pluggble enables the correct UX to be discovered through play, and tweaked with minimal effort.
Example 3: Redux Toolkit Counter (multi-mode)
All 5 modes can be tried in the polyrhythm version of the Redux Counter Example Sandbox
Can I use Promises instead of Observables?
Recall the auto-complete example, in which you could create a new search/results event from either a Promise or Observable:
on('search/start', ({ payload }) => {
// return Observable
return ajax.get(URL + payload).pipe(
tap({ results } => results)
);
// OR Promise
return fetch(URL + payload).then(res => res.json())
}, {
mode: 'replace',
trigger: { next: 'search/results' }
});
With either the Promise, or Observable, the mode: replace guarantees your autocomplete never has the race-condition where an old result populates after new letters invalidate it. But with an Observable:
- The AJAX can be canceled, freeing up bandwidth as well
- The AJAX can be set to be canceled implicitly upon component unmount, channel reset, or by another event declaratively with
takeUntil. And no Abort Controllers orawaitever required!
You have to return an Observable to get cancelation, and you only get all the overlap strategies and lean performance when you can cancel. So best practice is to use them - but they are not required.
UI Layer Bindings
trigger, filter listen (aka on), and query are methods bound to an instance of a Channel. For convenience, and in many examples, these bound methods may be imported and used directly
import { trigger, on } from 'polyrhythm';
on(...)
trigger(...)
These top-level imports are enough to get started, and one channel is usually enough per JS process. However you may want more than one channel, or have control over its creation:
import { Channel } from 'polyrhythm';
const channel = new Channel();
channel.trigger(...)
(In a React environment, a similar choice exists- a top-level useListener hook, or a listener bound to a channel via useChannel. React equivalents are discussed further in the polyrhythm-react repo)
To tie cancelation into your UI layer's component lifecycle (or server-side request fulfillment if in Node), call .unsubscribe() on the return value from channel.listen or channel.filter for any handlers the component set up:
// at mount
const sub = channel.on(...)..
// at unmount
sub.unsubscribe()
Lastly in a hot-module-reloading environment, channel.reset() is handy to remove all listeners, canceling their effects. Include that call early in the loading process to avoid double-registration of listeners in an HMR environment.
API
A polyrhythm app, sync or async, can be built out of 6 or fewer primitives:
trigger- Puts an event on the event bus, and should be called at least once in your app. Generally all a UI Event Handler needs to do is calltriggerwith an event type and a payload. <br/>Example — `addEventListener('click', ()=>{ trigger('t
