https://github.com/domfarolino/observable Skip to content Toggle navigation Sign up * Product + Actions Automate any workflow + Packages Host and manage packages + Security Find and fix vulnerabilities + Codespaces Instant dev environments + Copilot Write better code with AI + Code review Manage code changes + Issues Plan and track work + Discussions Collaborate outside of code Explore + All features + Documentation + GitHub Skills + Blog * Solutions For + Enterprise + Teams + Startups + Education By Solution + CI/CD & Automation + DevOps + DevSecOps Resources + Customer Stories + White papers, Ebooks, Webinars + Partners * Open Source + GitHub Sponsors Fund open source developers + The ReadME Project GitHub community articles Repositories + Topics + Trending + Collections * Pricing Search or jump to... Search code, repositories, users, issues, pull requests... Search [ ] Clear Search syntax tips Provide feedback We read every piece of feedback, and take your input very seriously. [ ] [ ] Include my email address so I can be contacted Cancel Submit feedback Saved searches Use saved searches to filter your results more quickly Name [ ] Query [ ] To see all available qualifiers, see our documentation. Cancel Create saved search Sign in Sign up You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session. You switched accounts on another tab or window. Reload to refresh your session. {{ message }} domfarolino / observable Public * Notifications * Fork 0 * Star 72 Observable API proposal 72 stars 0 forks Activity Star Notifications * Code * Issues 17 * Pull requests 1 * Actions * Projects 0 * Security * Insights More * Code * Issues * Pull requests * Actions * Projects * Security * Insights domfarolino/observable This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository. master Switch branches/tags [ ] Branches Tags Could not load branches Nothing to show {{ refName }} default View all branches Could not load tags Nothing to show {{ refName }} default View all tags Name already in use A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch? Cancel Create 2 branches 0 tags Code * Local * Codespaces * Clone HTTPS GitHub CLI [https://github.com/d] Use Git or checkout with SVN using the web URL. [gh repo clone domfar] Work fast with our official CLI. Learn more about the CLI. * Open with GitHub Desktop * Download ZIP Sign In Required Please sign in to use Codespaces. Launching GitHub Desktop If nothing happens, download GitHub Desktop and try again. Launching GitHub Desktop If nothing happens, download GitHub Desktop and try again. Launching Xcode If nothing happens, download Xcode and try again. Launching Visual Studio Code Your codespace will open once ready. There was a problem preparing your codespace, please try again. Latest commit @domfarolino domfarolino Make WebIDL valid; fixes #26 ... 4f3e01a Jul 28, 2023 Make WebIDL valid; fixes #26 4f3e01a Git stats * 25 commits Files Permalink Failed to load latest commit information. Type Name Latest commit message Commit time README.md Make WebIDL valid; fixes #26 July 28, 2023 09:35 View code [ ] Observable Introduction EventTarget integration Example 1 Example 2 Example 3 Example 4 Example 5 Example 6 The Observable API Lazy, synchronous delivery Firehose of synchronous data Operators Background & landscape History Userland libraries UI Frameworks Supporting Observables Concerns Standards venue Authors: README.md Observable This is the explainer for the Observable API proposal for more ergonomic and composable event handling. Introduction EventTarget integration This proposal adds an .on() method to EventTarget that becomes a better addEventListener(); specifically it returns a new Observable that adds a new event listener to the target when its subscribe() method is called. The Observable calls the subscriber's next() handler with each event. Observables turn event handling, filtering, and termination, into an explicit, declarative flow that's easier to understand and compose than today's imperative version, which often requires nested calls to addEventListener() and hard-to-follow callback chains. Example 1 // Filtering and mapping: element.on('click') .filter(e => e.target.matches('.foo')) .map(e => ({x: e.clientX, y: e.clientY })) .subscribe({next: handleClickAtPoint}); Example 2 // Automatic, declarative unsubscription via the takeUntil method: element.on('mousemove') .takeUntil(document.on('mouseup')) .subscribe({next: e => ... }); // Since reduce and some other terminators return promises, they also play // well with async functions: await element.on('mousemove') .takeUntil(element.on('mouseup')) .reduce((e, soFar) => ...); Imperative version // Imperative const controller = new AbortController(); element.addEventListener('mousemove', e => { element.addEventListener('mouseup', e => controller.abort()); console.log(e); }, {signal}); Example 3 Tracking all link clicks within a container (example): container.on('click').filter(e => e.target.closest('a')).subscribe({next: e => { // ... }}); Example 4 Find the maximum Y coordinate while the mouse is held down (example): const maxY = await element.on('mousemove') .takeUntil(element.on('mouseup')) .map(e => e.clientY) .reduce((y, soFar) => Math.max(y, soFar), 0); Example 5 Multiplexing a WebSocket, such that a subscription message is send on connection, and an unsubscription message is send to the server when the user unsubscribes. const socket = new WebSocket('wss://example.com'); function multiplex({ startMsg, stopMsg, match }) { if (socket.readyState !== WebSocket.OPEN) { return socket .on('open') .flatMap(() => multiplex({ startMsg, stopMsg, match })); } else { socket.send(JSON.stringify(startMsg)); return socket .on('message') .filter(match) .takeUntil(socket.on('close')) .takeUntil(socket.on('error')) .map((e) => JSON.parse(e.data)) .finally(() => { socket.send(JSON.stringify(stopMsg)); }); } } function streamStock(ticker) { return multiplex({ startMsg: { ticker, type: 'sub' }, stopMsg: { ticker, type: 'unsub' }, match: (data) => data.ticker === ticker, }); } function finish(stopMsg) { socket.send(JSON.stringify(stopMsg)); } const googTrades = streamStock('GOOG'); const nflxTrades = streamStock('NFLX'); const googController = new AbortController(); const googSubscription = googTrades.subscribe({next: updateView, signal: googController.signal}); const nflxSubscription = nflxTrades.subscribe({next: updateView, ...}); // And the stream can disconnect later, which // automatically sends the unsubscription message // to the server. googController.abort(); Imperative version // Imperative function multiplex({ startMsg, stopMsg, match }) { const start = (callback) => { const teardowns = []; if (socket.readyState !== WebSocket.OPEN) { const openHandler = () => start({ startMsg, stopMsg, match })(callback); socket.addEventListener('open', openHandler); teardowns.push(() => { socket.removeEventListener('open', openHandler); }); } else { socket.send(JSON.stringify(startMsg)); const messageHandler = (e) => { const data = JSON.parse(e.data); if (match(data)) { callback(data); } }; socket.addEventListener('message', messageHandler); teardowns.push(() => { socket.send(JSON.stringify(stopMsg)); socket.removeEventListener('message', messageHandler); }); } const finalize = () => { teardowns.forEach((t) => t()); }; socket.addEventListener('close', finalize); teardowns.push(() => socket.removeEventListener('close', finalize)); socket.addEventListener('error', finalize); teardowns.push(() => socket.removeEventListener('error', finalize)); return finalize; }; return start; } function streamStock(ticker) { return multiplex({ startMsg: { ticker, type: 'sub' }, stopMsg: { ticker, type: 'unsub' }, match: (data) => data.ticker === ticker, }); } const googTrades = streamStock('GOOG'); const nflxTrades = streamStock('NFLX'); const unsubGoogTrades = googTrades(updateView); const unsubNflxTrades = nflxTrades(updateView); // And the stream can disconnect later, which // automatically sends the unsubscription message // to the server. unsubGoogTrades(); Example 6 Here we're leveraging observables to match a secret code, which is a pattern of keys the user might hit while using an app: const pattern = [ 'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a', 'b', 'a', 'Enter', ]; const keys = document.on('keydown').map((e) => e.key); keys .flatMap((firstKey) => { if (firstKey === pattern[0]) { return keys .take(pattern.length - 1) .every((k, i) => k === pattern[i + 1]); } }) .filter(matched => matched) .subscribe({next: _ => { console.log('Secret code matched!'); }}); Imperative version const pattern = [...]; // Imperative document.addEventListener('keydown', e => { const key = e.key; if (key === pattern[0]) { let i = 1; const handler = (e) => { const nextKey = e.key; if (nextKey !== pattern[i++]) { document.removeEventListener('keydown', handler) } else if (pattern.length === i) { console.log('Secret code matched!'); document.removeEventListener('keydown', handler) } } document.addEventListener('keydown', handler) } }) The Observable API Observables are first-class objects representing composable, repeated events. They're like Promises but for multiple events, and specifically with EventTarget integration, they are to events what Promises are to callbacks. They can be: * Created by script or by platform APIs, and passed to anyone interested in consuming events via subscribe() * Fed to operators like Observable.map(), to be composed & transformed without a web of nested callbacks Better yet, the transition from event handlers [?] Observables is simpler than that of callbacks [?] Promises, since Observables integrate nicely on top of EventTarget, the de facto way of subscribing to events from the platform and custom script. As a result, developers can use Observables without migrating tons of code on the platform, since it's an easy drop-in wherever you're handling events today. The proposed API shape is as follows: partial interface EventTarget { Observable on(DOMString type, optional AddEventListenerOptions options); }; // `SubscribeCallback` is where the Observable "creator's" code lives. It's // called when `subscribe()` is called, to set up a new subscription. callback SubscribeCallback = undefined (Subscriber subscriber); callback ObserverCallback = undefined (any value); dictionary Observer { ObserverCallback next; VoidFunction complete; ObserverCallback error; AbortSignal signal; }; [Exposed=*] interface Subscriber { undefined next(any result); undefined complete(); undefined error(any error); readonly attribute AbortSignal signal; }; callback Predicate = boolean (any value); [Exposed=*] interface Observable { constructor(SubscribeCallback callback); undefined subscribe(Observer observer); undefined finally(VoidFunction callback); // Observable-returning operators. See "Operators" section below. // TODO: Use more specific callback types than `Function`. Observable takeUntil(Observable notifier); Observable map(Function project); Observable filter(Predicate predicate); Observable take(unsigned long long); Observable drop(unsigned long long); Observable flatMap(Function project); Observable toArray(); Observable forEach(Function callback); // Promise-returning. See "Concerns" section below. Promise every(Predicate predicate); // Maybe? Promise first(); Promise find(Predicate predicate); Promise some(Predicate predicate); Promise reduce(Function accumulator, optional any); }; The creator of an Observable passes in a callback that gets invoked synchronously whenever subscribe() is called. The subscribe() method can be called any number of times, and the callback it invokes sets up a new "subscription" by registering the caller of subscribe() as a Observer. With this in place, the Observable can signal any number of events to the Observer via the next() callback, optionally followed by a single call to either complete() or error(), signaling that the stream of data is finished. const observable = new Observable(subscriber => { let i = 0; setInterval(() => { if (i >= 10) subscriber.complete(); else subscriber.next(i++); }, 2000); }); observable.subscribe({ // Print each value the Observable produces. next: console.log }); Issue: See #3 about having the Observable constructor being able to register teardown upon unsubscription. While custom Observables can be useful on their own, the primary use case they unlock is with event handling. Observables returned by the new EventTarget#on() method are created natively with an internal callback that uses the same underlying mechanism as addEventListener (). Therefore calling subscribe() essentially registers a new event listener whose events are exposed through the Observer handler functions and are composable with the various combinators available to all Observables. Lazy, synchronous delivery Crucially, Observables are "lazy" in that they do not start emitting data until they are subscribed to, nor do they queue any data before subscription. They can also start emitting data synchronously during subscription, unlike Promises which always queue microtasks when invoking .then() handlers. Consider this example: el.on('click').subscribe({next: () => console.log('One')}); el.on('click').find(() => {...}).then(() => console.log('Three')); el.click(); console.log('Two'); // Logs "One" "Two" "Three" Firehose of synchronous data By using AbortController, you can unsubscribe from an Observable even as it synchronously emits data during subscription: // An observable that synchronously emits unlimited data during subscription. let observable = new Observable(subscriber => { let i = 0; while (true) { subscriber.next(i++); } }); let controller = new AbortController(); observable.subscribe({next: data => { if (data > 100) controller.abort(); }, signal: controller.signal}); Operators We propose the following operators in addition to the Observable interface: * takeUntil(Observable) + Returns an observable that mirrors the one that this method is called on, until the input observable emits its first value * finally() + Like Promise.finally(), it takes a callback which gets fired after the observable completes in any way (done()/error()) Versions of the above are often present in userland implementations of observables as they are useful for observable-specific reasons, but in addition to these we offer a set of common operators that follow existing platform precedent and can greatly increase utility and adoption. These exist on other iterables, and are derived from TC39's iterator helpers proposal which adds the following methods to Iterator.prototype: * map() * filter() * take() * drop() * flatMap() * reduce() * toArray() * forEach() * some() * every() * find() * maybe: from()^1 We expect userland libraries to provide more niche operators that integrate with the Observable API central to this proposal, potentially shipping natively if they get enough momentum to graduate to the platform. But for this initial proposal, we'd like to restrict the set of operators to those that follow the precedent stated above, similar to how web platform APIs that are declared Setlike and Maplike have native properties inspired by TC39's Map and Set objects. Therefore we'd consider most discussion of expanding this set as out-of-scope for the initial proposal, suitable for discussion in an appendix. Any long tail of operators could conceivably follow along if there is support for the native Observable API presented in this explainer. Note that the operators every(), find(), some(), and reduce() return Promises whose scheduling differs from that of Observables, which sometimes means event handlers that call e.preventDefault() will run too late. See the Concerns section which goes into more detail. Background & landscape To illustrate how Observables fit into the current landscape of other reactive primitives, see the below table which is an attempt at combining two other tables that classify reactive primitives by their interaction with producers & consumers: Singular Plural Spatial Temporal Spatial Temporal Push Value Promise Observable Pull Function Async iterator Iterable Async iterator History Observables were first proposed to the platform in TC39 in May of 2015. The proposal failed to gain traction, in part due to some opposition that the API was suitable to be a language-level primitive. In an attempt to renew the proposal at a higher level of abstraction, a WHATWG DOM issue was filed in December of 2017. Despite ample developer demand, lots of discussion, and no strong objectors, the DOM Observables proposal sat mostly still for several years (with some flux in the API design) due to a lack of implementer prioritization. Later in 2019, an attempt at reviving the proposal was made back at the original TC39 repository, which involved some API simplifications and added support for the synchronous "firehose" problem. This repository is an attempt to again breath life into the Observable proposal with the hope of shipping a version of it to the Web Platform. Userland libraries In prior discussion, Ben Lesh has listed several custom userland implementations of observable primitives, of which RxJS is the most popular with "47,000,000+ downloads per week." * RxJS: Started as a reference implementation of the TC39 proposal, is nearly identical to this proposal's observable. * Relay: A mostly identical contract with the addition of start and unsubscribe events for observation and acquiring the Subscription prior to the return. * tRPC: A nearly identical implemention of observable to this proposal. * XState: uses an observable interface in several places in their library, in particular for their Actor type, to allow subscriptions to changes in state, as shown in their useActor hook. Using an identical observable is also a documented part of access state machine changes when using XState with SolidJS. * SolidJS: An identical interface to this proposal is exposed for users to use. * Apollo GraphQL: Actually re-exporting from zen-observable as their own thing, giving some freedom to reimplement on their own or pivot to something like RxJS observable at some point. * zen-observable: A reference implementation of the TC39 observable proposal. Nearly identical to this proposal. * React Router: Uses a { subscribe(callback: (value: T) => void): () => void } pattern in their Router and DeferredData code. This was pointed out by maintainers as being inspired by Observable. * Preact Uses a { subscribe(callback: (value: T) => void): () => void } interface for their signals. * TanStack: Uses a subscribable interface that matches { subscribe (callback: (value: T) => void): () => void } in several places * Redux: Implements an observable that is nearly identical to this proposal's observable as a means of subscribing to changes to a store. * Svelte: Supports subscribing to observables that fit this exact contract, and also exports and uses a subscribable contract for stores like { subscribe(callback: (value: T) => void): () => void }. * Dexis.js: Has an observable implementation that is used for creating live queries to IndexedDB. * MobX: Uses similar interface to Observable internally for observation: { observe_(callback: (value: T)): () => void }. UI Frameworks Supporting Observables * Svelte: Directly supports implicit subscription and unsubscription to observables simply by binding to them in templates. * Angular: Directly supports implicit subscription and unsubscription to observables using their | async "async pipe" functionality in templates. * Vue: maintains a dedicated library specifically for using Vue with RxJS observables. * Cycle.js: A UI framework built entirely around observables Given the extensive prior art in this area, there exists a public " Observable Contract". Additionally many JavaScript APIs been trying to adhere to the contract defined by the TC39 proposal from 2015. To that end, there is a library, symbol-observable, that ponyfills (polyfills) Symbol.observable to help with interoperability between observable types that adheres to exactly the interface defined here. symbol-observable has 479 dependent packages on npm, and is downloaded more than 13,000,000 times per week. This means that there are a minimum of 479 packages on npm that are using the observable contract in some way. This is similar to how Promises/A+ specification that was developed before Promises were adopted into ES2015 as a first-class language primitive. Concerns One of the main concerns expressed in the original WHATWG DOM thread has to do with Promise-ifying APIs on Observable, such as the proposed first(). The potential footgun here with microtask scheduling and event integration. Specifically, the following innocent-looking code would not always work: element.on('click').first.then(e => { e.preventDefault(); // Do something custom... }); If Observable#first() returns a Promise that resolves when the first event is fired on an EventTarget, then the user-supplied Promise .then() handler will run: * Synchronously after event firing, for events triggered by the user * Asynchronously after event firing, for all events triggered by script (i.e., element.click()) + This means e.preventDefault() will have happened too late and effectively been ignored To understand why this is the case, you must understand how and when the microtask queue is flushed (and thus how microtasks, including Promise resolution handlers, are invoked). In WebIDL after a callback is invoked, the HTML algorithm clean up after running script is called, and this algorithm calls perform a microtask checkpoint if and only if the JavaScript stack is empty. Concretely, that means for element.click() in the above example, the following steps occur: 1. To run element.click(), a JavaScript execution context is first pushed onto the stack 2. To run the internal click event listener callback (the one created natively by the Observable#from() implementation), another JavaScript execution context is pushed onto the stack, as WebIDL prepares to run the internal callback 3. The internal callback runs, which immediately resolves the Promise returned by Observable#first(); now the microtask queue contains the Promise's user-supplied then() handler which will cancel the event once it runs 4. The top-most execution context is removed from the stack, and the microtask queue cannot be flushed, because there is still JavaScript on the stack. 5. After the internal click event callback is executed, the rest of the event path continues since event was not canceled during or immediately after the callback. The event does whatever it would normally do (submit the form, alert() the user, etc.) 6. Finally, the JavaScript containing element.click() is finished, and the final execution context is popped from the stack and the microtask queue is flushed. The user-supplied .then() handler is run, which attempts to cancel the event too late Two things mitigate this concern. First, there is a very simple workaround to always avoid the case where your e.preventDefault() might run too late: element.on('click').map(e => (e.preventDefault(), e)).first() ...or if Observable had a .do() method (see whatwg/dom#544 (comment) ): element.on('click').do(e => e.preventDefault()).first() ...or by modifying the semantics of first() to take a callback that produces a value that the returned Promise resolves to: el.on("submit").first(e => e.preventDefault()).then(doMoreStuff) Second, this "quirk" already exists in today's thriving Observable ecosystem, and there are no serious concerns or reports from that community that developers are consistently running into this. This gives some confidence that baking this behavior into the web platform will not be dangerous. Standards venue There's been much discussion about which standards venue should ultimately host an Observables proposal. The venue is not inconsequential, as it effectively decides whether Observables becomes a language-level primitive like Promises, that ship in all JavaScript browser engines, or a web platform primitive with optional consideration in other environments like Node.js (see AbortController for example). In previous discussion it had been decided that WHATWG DOM Standard is the right home for Observables due to its integration with the web platform event event system and lack of new syntax or language capabilities. In attempt to avoid relitigating this discussion, we'd urge the reader to see the following discussion comments: * whatwg/dom#544 (comment) * whatwg/dom#544 (comment) * whatwg/dom#544 (comment) * whatwg/dom#544 (comment) * whatwg/dom#544 (comment) Authors: * Dominic Farolino * Ben Lesh Footnotes 1. This appears in the TC39 proposal's README.md file but not the spec, so its fate is unclear. - About Observable API proposal Topics javascript html dom observable web-application reactive-programming whatwg observables Resources Readme Activity Stars 72 stars Watchers 8 watching Forks 0 forks Report repository Releases No releases published Packages 0 No packages published Contributors 2 * @domfarolino domfarolino Dominic Farolino * @benlesh benlesh Ben Lesh Footer (c) 2023 GitHub, Inc. Footer navigation * Terms * Privacy * Security * Status * Docs * Contact GitHub * Pricing * API * Training * Blog * About You can't perform that action at this time.