Let's explore how the approaches to describing asynchronous dependencies in programs have evolved — from callbacks all the way to signals. We'll walk through several key primitives, understand what problems each one solves, and see how they all connect to one another.
- Programs as dependency graphs
- Function "colors": sync vs async
- Callbacks
- The Thenable pattern
- Pipe Operator
- Promise
- Future
- Observable
- Signals
- Async Signals and what's ahead
There are many ways to think about a program:
- A sequence of instructions — line by line, top to bottom.
- A collection of objects exchanging messages.
- A collection of functions transforming data.
- A collection of actions linked by dependencies — and this is the perspective we'll focus on here.
All of these views are valid; they simply highlight different facets of the same thing.
Let's dwell on that last point. A program is a set of actions (computations) with dependencies between them. Action B depends on the result of action A. Action C depends on both B and D. Action E can only start after C and F have finished.
If we draw those dependencies, we get a graph. It's directed — because dependencies have a direction: A must complete before B, not the other way around. And it's acyclic — because cycles would mean a deadlock: A waits for B, B waits for C, C waits for A — there's no way out. In real-world reactive systems cyclic dependencies do exist, but they're always broken by special mechanisms like initial values or feedback loops. The baseline model is a DAG (directed acyclic graph).
Throughout this text we'll refer to such a graph as an FRP network.
Imagine we need to load a user profile page. We have to fetch the user's data, then their orders and settings (both require the user's ID), and finally render the page once all the data is ready.
fetchUser
/ \
↓ ↓
fetchOrders fetchSettings
\ /
↓ ↓
renderPage
Four nodes, four edges. fetchOrders and fetchSettings don't depend on each other, so they can run in parallel. A runtime that "sees" the graph structure can take advantage of this. If we wrote the same logic as a sequence of imperative steps, that opportunity for parallelism would be lost.
This example will stay with us throughout. We'll keep coming back to it and see how each new primitive describes this network — and how naturally it does so.
Key takeaway. Everything that follows is about the tools we have for describing the nodes and edges of an FRP network.
Before we get to specific tools, let's take a closer look at what synchronous and asynchronous actually mean. These terms are used everywhere, yet their meaning is surprisingly blurry. Let's try to arrive at a working definition.
Synchrony is a property of code where the order of actions is known in advance and fully determined by the program itself.
When we write a(); b(); c();, we know exactly what happens: a runs first, then b, then c. The order is dictated by the structure of the code and doesn't depend on anything external. Predictable, unambiguous, no surprises.
function getUser(): User {
return cache.get("user");
}
const user = getUser(); // 1. got the user
const orders = getOrders(user.id); // 2. got orders — guaranteed to come after
renderPage(user, orders); // 3. rendered — guaranteed to come afterAsynchronous code is code in which the order of actions is not fully determined in advance and depends on external events.
A common way to describe asynchrony is "a function that will return its result sometime later." That's a misleading characterization — it gives the impression that an async function is simply a "slow synchronous one." The difference runs deeper than that: in asynchronous code, part of the execution order is determined not by the program, but by the outside world.
Think of it this way. A program with a big switch block that handles keystrokes is, in a sense, already asynchronous code. The order in which the switch branches execute is determined not by the program, but by the user. The program reacts to external events.
More often, though, it's useful to think about asynchrony in a more specific sense: asynchronous functions are a set of functions that another part of the program (or the runtime environment) calls in an order determined by external events. We don't call them ourselves at the right moment — we register them ahead of time, and the environment decides when exactly to invoke them. A response arrives from the server — the environment calls one function. The user clicks — the environment calls another. A timer expires — a third one.
// We don't call handleResponse ourselves — the environment will, when a response arrives
fetch("/api/user").then(handleResponse);
// We don't call handleClick ourselves — the browser will, when the user clicks
button.addEventListener("click", handleClick);
// We don't call tick ourselves — the environment will, after 1000ms
setTimeout(tick, 1000);In all three cases the pattern is the same: we hand over a function, and when it gets called is decided by something other than us.
At first glance, async/await makes asynchronous code look like synchronous code. That's convenient for reading, but it creates the misleading impression that nothing special is going on. In reality, there's significant machinery behind the syntax.
When we write:
async function loadProfile(id: string) {
const user = await fetchUser(id); // ← split point
const orders = await fetchOrders(user.id); // ← another one
return renderPage(user, orders);
}The JavaScript engine slices this function into several parts — one at each await — building an implicit state machine. Each part is a separate chunk of code that can be executed independently. Between parts the function is suspended: control is handed back to the runtime, and the local variables are captured in a closure so the next part can pick up where the previous one left off.
In essence, here's what happens behind the scenes (simplified pseudocode):
// Part 1: before the first await
function loadProfile_part1(id: string) {
const userPromise = fetchUser(id);
// save local variables (id) into the function's "frame"
// register loadProfile_part2 as the continuation
// return control to the runtime
}
// Part 2: runs when fetchUser resolves — the runtime passes the result as `user`
function loadProfile_part2(user: User) {
const ordersPromise = fetchOrders(user.id);
// save local variables into the function's "frame"
// register loadProfile_part3 as the continuation
// return control to the runtime
}
// Part 3: runs when fetchOrders resolves — the runtime passes the result as `orders`
function loadProfile_part3(orders: Order[]) {
const user = /* restored from the saved frame */;
return renderPage(user, orders);
}So async/await is syntactic sugar that slices our function into parts and hands execution of each subsequent part to another place (the event loop, a scheduler, an I/O subsystem), leaving a reference to the continuation in special internal runtime tables. We write one linear function, but the runtime sees a set of separate fragments connected to each other.
This isn't an abstract implementation detail — it directly affects program behavior. Between two awaits, anything can happen: other code can run, state can change, other asynchronous operations can complete. Each await is a point where we give up control and don't know when we'll get it back.
If we look at async/await through the lens of an FRP network, it becomes clear that this syntax is a way of describing a dependency graph:
async function loadProfile(id: string) {
const user = await fetchUser(id);
// ↑ edge: loadProfile_part2 depends on fetchUser
const orders = await fetchOrders(user.id);
// ↑ edge: loadProfile_part3 depends on fetchOrders
return renderPage(user, orders);
}Each await is an edge in the graph: "the next fragment depends on the result of this operation." Sequential awaits form a linear chain. But notice: in this code, fetchOrders only begins after fetchUser completes — even though, looking at our FRP diagram from section 1, fetchOrders and fetchSettings could run in parallel. Linear await forces sequentiality where the dependency graph doesn't require it.
To express parallelism, we have to explicitly switch to a different pattern:
async function loadProfile(id: string) {
const user = await fetchUser(id);
// launch both in parallel, await the combined result
const [orders, settings] = await Promise.all([
fetchOrders(user.id),
fetchSettings(user.id),
]);
return renderPage(user, orders, settings);
}Here Promise.all is an explicit join node in the graph, and both calls run in parallel. But notice: the await syntax alone can't express this — we needed an additional primitive (Promise.all) to describe the branching.
An important distinction worth noting. Asynchronous code separates calling a function from receiving its result — but that doesn't mean multiple operations run at the same time. A nice illustration of this is the approach taken by the Zig language, which recently reintroduced async/await in a redesigned form. Zig offers two distinct calls: async (separates the call from the return but doesn't guarantee simultaneous execution) and concurrent (explicitly requires parallel execution and returns an error if the environment can't provide it). This separation makes the programmer's intent explicit: "I want to defer the result" is one thing, "I need this to run simultaneously" is another.
JavaScript doesn't have this separation at the syntax level — async/await and Promise.all blend both ideas together. But understanding that asynchrony and parallelism are different things helps us design FRP networks more deliberately.
There's a classic article by Bob Nystrom called «What Color Is Your Function?». The metaphor goes like this: every function in a language is one of two "colors" — blue (sync) or red (async). The rule: you can only call a red function from another red function. If you need to call a red function from a blue one, you'll have to repaint the blue one red. Which means everyone calling your function has to become red too. And so on, up the chain.
That's exactly what happens with async/await in JavaScript and TypeScript. Async is contagious: once getUser becomes asynchronous, the entire calling chain has to adapt.
But here's the thing: function "colors" are a design decision at the language syntax level, not an inescapable law of nature. Different languages handle this differently:
- JavaScript/TypeScript — two colors:
functionandasync function. Async is contagious; you can't mix them transparently. - Go — "colorless" functions. Goroutines look like ordinary calls; the language hides the distinction. The tradeoff is a runtime with a scheduler and goroutines.
- Zig — a single function syntax, with the choice between sync/async/concurrent made at the call site, not the declaration. The same function can be called synchronously or asynchronously — the caller decides. Switching the I/O backend (single-thread, multi-thread, io_uring) doesn't require rewriting code.
- Rust —
async fnreturns aFuturethat is lazy. Two-coloredness exists, but Futures don't start running when created — only on.await.
Each option is a tradeoff: between explicitness and convenience, between control and simplicity, between performance and generality. JavaScript chose the path of explicit colors — and that decision is what gives rise to the whole spectrum of primitives we explore below. Each one is an attempt to answer the question: how do we describe a dependency graph in which some nodes are synchronous and some are asynchronous?
A callback is a function passed into another function to be called upon completion. It's historically the first and most straightforward way to describe an edge in an FRP network: "when action A finishes — run action B."
readFile("/users/1.json", (err, data) => {
if (err) {
handleError(err);
return;
}
parseJSON(data, (err, user) => {
if (err) {
handleError(err);
return;
}
fetchOrders(user.id, (err, orders) => {
if (err) {
handleError(err);
return;
}
renderPage(user, orders);
});
});
});We've described the chain readFile → parseJSON → fetchOrders → renderPage. Each nested callback is an edge in the graph. It works. But there are some real problems.
Callback hell. Each new node in the chain adds another level of nesting. The code drifts to the right. This is the most well-known issue, but it's actually the least serious one — it's essentially cosmetic.
Loss of composability. A linear chain is manageable. But try expressing our example graph with callbacks: fetchOrders and fetchSettings run in parallel, the render waits for both. You'll end up with a completion counter, mutable result variables, and manual checks for whether all responses have arrived. It gets fragile fast.
Inversion of control. We're passing our function into someone else's code. That other code decides when to call our callback, how many times, and whether to call it at all. There's no contract, no guarantees.
No standard error handling. Node.js invented the (err, result) convention. Browser APIs use separate error callbacks. Some code throws exceptions that no one can catch in an asynchronous context. There's no unified approach.
Callbacks are a foundational building block. Every abstraction we'll look at next — promises, futures, observables, signals — relies on callbacks under the hood in one way or another. We work at a higher level of abstraction, but at the base it's always the same mechanism: "hand over a function — it will get called."
A thenable is an object with a .then() method. It's a pattern that standardizes the interface for "when done — call next" and turns callback hell into a chain.
When using callbacks, we pass our function into the async operation — and lose control. Thenable flips this: the async function returns an object, and we, on the outside, call its .then() method.
// Callbacks: we pass a function in (control is with the callee)
doSomething(myCallback);
// Thenable: we get an object back (control is with the caller)
const result = doSomething();
result.then(myCallback);It seems like a small thing — we just moved where the callback is passed. But it's actually a fundamental shift. Control has returned to the calling code. We decide when to subscribe. We can store the thenable in a variable, pass it somewhere else, subscribe later.
If .then() returns a new thenable, we can build chains:
fetchUser(id)
.then((user) => fetchOrders(user.id))
.then((orders) => renderPage(orders));The same graph that used to be expressed with nested callbacks is now written linearly. It reads top to bottom, doesn't drift to the right.
An important point: thenable is an interface, not a specific implementation. Any object with a .then() method qualifies as a thenable:
interface Thenable<T> {
then(onFulfilled?: (value: T) => any, onRejected?: (reason: any) => any): any;
}It's a minimal contract for interoperability. Thanks to such a simple interface, different libraries and native implementations can work with each other seamlessly.
The TC39 Pipe Operator (|>) is a proposal for JavaScript that addresses the function composition problem at the language syntax level.
Nested function calls read inside-out:
render(format(capitalize(trim(input))));
// 4. 3. 2. 1. ← execution orderTo understand the order, you have to start from the innermost parentheses. That's unnatural — people are used to reading left to right, top to bottom.
Pipe operator (Hack-style, the current TC39 favorite):
input |> trim(%) |> capitalize(%) |> format(%) |> render(%)
// 1. 2. 3. 4. ← execution order% is a placeholder for the result of the previous step. The data flow reads exactly in execution order — like an FRP graph, left to right.
The pipe operator is primarily about composition: data flows left to right through a sequence of transformations. The same idea as thenable chains, but at the language syntax level.
The proposal is at Stage 2 in TC39 at the moment, still a long way from being the standard. However, the idea has already made its way into the ecosystem through libraries: RxJS pipe(), fp-ts pipe(), Ramda pipe().
In other languages, the pipe operator is part of the syntax: Elixir (|>), F# (|>), OCaml (|>), Elm (|>).
Promise is arguably the most important asynchronous programming primitive in JavaScript. Let's look at it from three angles.
A promise is a container that, at any given moment, is in one of three states:
┌──→ fulfilled (value received)
pending ───────┤
└──→ rejected (error occurred)
The transition from pending to fulfilled or rejected is one-time and irreversible. Once a promise resolves with the value 42, it holds 42 forever. It can't be overwritten, rolled back, or reset. Immutable after settlement.
A set of guarantees that callbacks never had:
- The
.then()callback is invoked exactly once — not zero, not two. - The invocation is always asynchronous (via a microtask), even if the promise is already resolved at the time of subscription. This guarantees a predictable execution order.
.catch()provides standardized error handling. An error not caught in one.then()propagates down the chain..finally()provides cleanup logic, called regardless of outcome.
Let's return to our example graph:
const userPromise = fetchUser(id);
const ordersPromise = userPromise.then((user) => fetchOrders(user.id));
const settingsPromise = userPromise.then((user) => fetchSettings(user.id));
Promise.all([ordersPromise, settingsPromise]).then(([orders, settings]) =>
renderPage(orders, settings),
);In graph terms:
.then()— an edge: "action B depends on the result of action A."Promise.all()— a join node: "wait for all incoming edges to complete, then continue."ordersPromiseandsettingsPromiseare independent of each other — the runtime runs them in parallel.
Other combinators:
| Combinator | Semantics |
|---|---|
Promise.all() |
Wait for all. If any one rejects — everything rejects |
Promise.race() |
Whoever finishes first — that's the result (fulfilled or rejected) |
Promise.allSettled() |
Wait for all, including failures |
Promise.any() |
First successful one. If all reject — AggregateError |
Promises have two fundamental limitations worth keeping in mind.
Eagerness. A promise starts executing the moment it's created. new Promise((resolve) => { ... }) — the code inside the constructor runs immediately. You can't create a promise, set it aside, and start it later. There's no "start" button. This means describing the graph and running it aren't separated — each node starts the moment it's described.
One-shot nature. A promise can resolve exactly once with a single value. It can't emit a stream of values. For one-off operations (an HTTP request) that's fine, but for a stream of clicks, WebSocket messages, or timer ticks — it doesn't fit.
These limitations are deliberate design choices, not bugs. But they do motivate the existence of other primitives.
A Future is conceptually the same as "a value that will be obtained in the future," but with one key difference: laziness. The computation doesn't begin until someone explicitly asks for it.
Note: Future is not a native JavaScript construct. Unlike
Promise, which is built into the language, Future comes from external libraries or other languages/platforms. The examples below use it as a conceptual contrast to Promise.
// Promise — eager: the request is already in flight
const p = new Promise((resolve) => {
fetch("/api/user").then((r) => resolve(r.json()));
});
// ↑ by this point the request is in flight, even if the result isn't needed yet
// Future — lazy: so far it's just a recipe
const f = Future((resolve) => {
fetch("/api/user").then((r) => resolve(r.json()));
});
// ↑ nothing has happened
f.run(); // ← now the request firesSeparation of description and execution. You can fully describe the dependency graph — all nodes and edges — and only then run it. The runtime can analyze the entire graph before starting execution and optimize accordingly.
Re-execution. A promise is one-shot: once resolved, it holds that result forever. A Future is a recipe; it can be executed again. f.run() → a result. f.run() once more → a fresh result. This makes Future well suited for retry logic, polling, data refresh.
Cancellation. If a Future hasn't been started yet, there's nothing to cancel. If it has, the implementation can provide a cancellation mechanism. Promises have no native cancellation.
Referential transparency. A concept from functional programming: an expression is referentially transparent if it can be replaced by its value without changing program behavior. A promise is not referentially transparent because creating it has a side effect (it starts the computation). A Future is referentially transparent because creating it starts nothing.
| Property | Promise | Future |
|---|---|---|
| Execution | Eager (immediate) | Lazy (deferred) |
| Re-execution | Not possible | Possible |
| Cancellation | No (natively) | Yes |
| Referential transparency | No | Yes |
| Number of values | One | One |
- Fluture — the most complete implementation for JS
- fp-ts —
TaskandTaskEithertypes - Analogues in other languages:
IOin Haskell,Taskin Scala (cats-effect)
Future solves the eagerness problem but doesn't solve the one-shot problem. Like a promise, a Future describes a single value. What if the data source produces a stream of values over time?
Observable is one of the most elegant abstractions in programming. Its beauty lies in how remarkably powerful behavior emerges from such a simple structure.
Note: Observable is not native to JavaScript. It is provided by libraries such as RxJS.
Here's the entirety of an Observable:
type Observer<T> = {
next: (value: T) => void; // next value
error: (err: any) => void; // error (terminal)
complete: () => void; // completion (terminal)
};
type Observable<T> = {
subscribe: (observer: Observer<T>) => Unsubscribe;
};An Observable is an object with a single subscribe method that takes an observer (an object with three callbacks) and returns an unsubscribe function. Essentially — a function that accepts three callbacks and returns teardown logic. Recognize it? The very same callbacks we started with, wrapped in a standardized contract.
Laziness. Like a Future, an Observable is lazy. Until someone calls subscribe(), nothing happens. Subscribing is starting.
Multiple values. Unlike Promise and Future, an Observable can emit 0, 1, 10, infinitely many values. It's a fundamentally different model: not "one value someday" but "a stream of values over time."
Push model. The source decides when to send the next value. The subscriber doesn't request data — it receives it. (As opposed to the pull model, where the consumer asks for the next element, as with iterators.)
On top of the base structure, a set of operators is defined — functions that take an Observable and return a new Observable:
const search$ = fromEvent(input, "keyup").pipe(
map((e) => e.target.value), // transform: event → string
debounceTime(300), // wait: 300ms of silence
distinctUntilChanged(), // deduplication
switchMap((q) => fromFetch(`/api/search?q=${q}`)), // nested async
map((res) => res.items), // transform result
);
search$.subscribe((items) => render(items)); // start the networkEach operator describes a node or edge in the graph. The main ones:
| Operator | What it does |
|---|---|
map |
Transforms each value |
filter |
Passes only matching values |
mergeMap |
Each value spawns a new Observable; results merge |
switchMap |
Like mergeMap, but cancels the previous inner Observable on new input |
combineLatest |
Takes the latest values from multiple Observables and combines them |
merge |
Merges several streams into one |
zip |
Pairs values from multiple streams element by element |
From a single primitive (subscribe/observer) and a set of combinators (operators), the ability to describe any FRP network emerges. HTTP requests, WebSocket streams, DOM events, timers, animations, state management, routing — all through a single primitive.
This is what complex systems theory calls emergence: from simple elements following simple rules, complex behavior arises that isn't obvious from the properties of the individual elements.
| Promise | Future | Observable | |
|---|---|---|---|
| Execution | Eager | Lazy | Lazy |
| Values | 1 | 1 | 0..∞ |
| Model | — | — | Push |
| Cancellation | No | Yes | Yes (unsubscribe) |
An important point to keep in mind: an Observable describes a set of actions — discrete events that occur over time. Each next() is an action: "a click happened," "a message arrived," "the server responded." An Observable doesn't hold a "current value" — it records that something happened.
This is a great fit for events. But problems arise when Observables are used to model state — something that "currently looks like this." Consider this example:
const price$ = fromWebSocket("/prices"); // stream of prices
const quantity$ = fromInput("#quantity"); // stream of quantity input
const total$ = combineLatest([price$, quantity$]).pipe(
map(([price, quantity]) => price * quantity),
);combineLatest combines the latest values from two streams. But what does "latest value" mean for a stream of events? If price$ updates while quantity$ hasn't yet, total$ recalculates with the new price and the old quantity. That might be correct, or it might produce a glitch — a momentary inconsistent state.
A glitch occurs when a node in the FRP network recalculates before all of its dependencies have updated. Here's a simple example:
const a$ = new BehaviorSubject(1);
const b$ = a$.pipe(map((a) => a * 2)); // b = a * 2
const c$ = combineLatest([a$, b$]).pipe(
map(([a, b]) => a + b), // c = a + b, expecting a + a * 2 = 3a
);When a$ emits a new value (say, 2), here's what happens: combineLatest receives the new value of a$ (2) and the still-old value of b$ (2, because b$ hasn't recalculated yet). For a brief moment c$ computes as 2 + 2 = 4, even though the correct answer is 2 + 4 = 6. That's a glitch: subscribers of c$ will see a transient incorrect value.
Why does this happen? Because Observable models actions (events), and we're trying to use it as state. Actions have no notion of "all dependencies should update synchronously" — each event is processed as it arrives. An Observable doesn't know the topology of the entire graph — each operator only sees its own immediate inputs.
This is a fundamental limitation of the push model that Observable operates in. And it's precisely what motivates the creation of Signals, which we'll look at next.
- Steep learning curve: "what's subscribed? when? how many subscribers? who unsubscribed?" — in complex chains, these questions can be hard to answer.
- Glitch updates when modeling state through event streams.
- Overkill for simple cases: a single HTTP request — Promise is simpler and sufficient.
- RxJS — the most mature implementation (hundreds of operators)
- TC39 Observable Proposal (Stage 1) — Observable may become native someday
- Most.js — an alternative, higher-performance implementation
Signal is a reactivity primitive that has experienced a true renaissance in recent years. Angular moved to signals, Solid.js is built around them, Preact Signals exists, and Vue's ref()/computed() are essentially signals under a different name.
Note: Signals are not yet native to JavaScript, though there is an active TC39 proposal to add them to the language.
interface Signal<T> {
get value(): T; // synchronous access to the current value
}
interface WritableSignal<T> extends Signal<T> {
set(value: T): void;
}
// Usage
const count = signal(0);
console.log(count.value); // 0
count.set(5);
console.log(count.value); // 5A Signal is a container that holds a current value and automatically notifies dependents when it changes.
The key difference from Observable: an Observable has no current value. An Observable is a stream of events, and if you subscribe at some point, you only get future events. A Signal always has a current value, available synchronously.
Derived (computed) signals are what makes signals a tool for describing FRP networks:
const firstName = signal("John");
const lastName = signal("Doe");
const fullName = computed(() => firstName.value + " " + lastName.value);
// fullName.value === 'John Doe'
firstName.set("Jane");
// fullName.value === 'Jane Doe' ← recalculated automaticallyfullName is a node in the FRP graph that depends on firstName and lastName. The reactivity system automatically tracks which signals were read inside the computed, and builds the dependency graph for us. This is called automatic dependency tracking — there's no need to explicitly declare dependencies; they're determined at execution time.
Imagine: there's a signal a, b depends on it, and c depends on both a and b. When a changes, b needs to update, then c. If c updated before b, it would see the new value of a and the old value of b — an inconsistency known as a glitch.
Signals solve this through batching: updates are collected and applied in the correct topological order. c is guaranteed to recalculate only after b.
| Aspect | Observable | Signal |
|---|---|---|
| Model | Push (event stream) | Pull + push-notification |
| Current value | No (events only) | Always available |
| Glitch-free | Depends on the operator | Yes (batching) |
| Best suited for | Events, streams, async | State, UI |
| Subscription | Manual (subscribe/unsubscribe) | Automatic (dependency tracking) |
Observables and Signals aren't competitors. They cover two different aspects of reactivity:
Observable — for events: clicks, WebSocket messages, timers, HTTP responses. Discrete points in time. Between two events there may be no "current value."
Signal — for state: the current user, the selected theme, a form value, a cart counter. A current value is always there.
Together they cover both fundamental facets of reactivity: events and state. In modern frameworks you'll typically find both: signals for state, observable-like mechanisms for events and side effects.
| Framework / Library | Implementation |
|---|---|
| Angular | signal(), computed(), effect() |
| Solid.js | createSignal(), createMemo(), createEffect() |
| Preact | @preact/signals |
| Vue | ref(), computed(), watch() |
| TC39 | Signals Proposal |
The signals we looked at above are synchronous. Set a value — it updates instantly. Read a computed — it recalculates synchronously. That works beautifully for UI state. But in the real world, data comes from the network, from databases, from APIs — and all of those operations are asynchronous.
// ✗ Not possible — computed is synchronous
const user = computed(async () => await fetchUser(id.value));computed has to return a value right now. Not a promise, not "later" — right now. And fetchUser is an asynchronous operation.
The solution is a signal that holds not just T, but a loading state:
type AsyncState<T> =
| { status: "pending" } // loading
| { status: "fulfilled"; value: T } // loaded
| { status: "rejected"; error: Error }; // error
const user: Signal<AsyncState<User>> = asyncComputed(() => fetchUser(id.value));
// user.value.status === 'pending' → show skeleton
// user.value.status === 'fulfilled' → render data
// user.value.status === 'rejected' → show error| Framework | API | Approach |
|---|---|---|
| Angular | resource() |
Signal with loading/loaded/error states |
| Solid.js | createResource() |
Resource with Suspense integration |
| React | use() + Suspense |
Promise "unwrapped" through a Suspense boundary |
At the async signals level, all the primitives we've explored begin to flow into each other:
- An Observable stream from a WebSocket can update a Signal
- A Signal change can spawn a Promise (HTTP request)
- A Promise can resolve the AsyncState inside a Signal
- An Observable can project into a Signal via
toSignal()
The boundaries between primitives aren't rigid. And there are reasons to expect that in the future, the boundary between synchronous and asynchronous reactivity will gradually blur at the level of language or framework primitives.
We've walked through a spectrum of primitives, each offering its own way to describe an FRP network — a dependency graph between actions in a program.
Each successive primitive didn't replace the one before it — it added a new way to describe the nodes and edges of the network. Understanding the full spectrum lets you choose the right tool for a given task: not because one tool is "better," but because you understand which part of the graph you need to describe, and which primitive does it most naturally.
- Bob Nystrom — «What Color Is Your Function?»
- André Staltz — «The introduction to Reactive Programming you've been missing»
- Ryan Carniato — «A Hands-on Introduction to Fine-Grained Reactivity»
- TC39 Observable Proposal
- TC39 Signals Proposal
- TC39 Pipe Operator Proposal
- RxJS Documentation
- Fluture — Fantasy Land compliant Futures
- Charles Fonseca — «Async/Await is finally back in Zig» — on the distinction between async and concurrent, colorless functions
- Andrew Kelley — «Zig's New Async I/O» — the design of Zig's new async system by the language's creator