Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/src/config/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
"label": "Creating Operators",
"slug": "core-concepts/creating-operators"
},
{
"label": "Higher-Order Operators",
"slug": "core-concepts/higher-order-operators"
},
{
"label": "The Problems Solves",
"slug": "core-concepts/the-problem-solves"
Expand Down Expand Up @@ -99,6 +103,10 @@
"label": "debounce",
"slug": "operator-api/debounce"
},
{
"label": "exhaustMap",
"slug": "operator-api/exhaust-map"
},
{
"label": "extend",
"slug": "operator-api/extend"
Expand All @@ -107,6 +115,10 @@
"label": "filter",
"slug": "operator-api/filter"
},
{
"label": "flatMap",
"slug": "operator-api/flat-map"
},
{
"label": "map",
"slug": "operator-api/map"
Expand Down Expand Up @@ -135,6 +147,10 @@
"label": "spy",
"slug": "operator-api/spy"
},
{
"label": "switchMap",
"slug": "operator-api/switch-map"
},
{
"label": "throttle",
"slug": "operator-api/throttle"
Expand Down
93 changes: 93 additions & 0 deletions docs/src/content/core-concepts/higher-order-operators.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Higher-Order Operators

Higher-order operators transform streams where each signal creates a new "inner stream". They control how these inner streams are managed over time.

## The Problem

Consider a search input that fetches results on each keystroke:

```typescript
keyboard(searchInput)
.pipe(
map((signal) => fetchResults(signal.value.key)) // Returns a Stream, not a value!
)
```

This produces a `Stream<Stream<Results>>`—a stream of streams. Higher-order operators flatten this into a single stream while controlling concurrency.

## Comparison

| Operator | Behavior | New Signal Arrives While Active |
|----------|----------|--------------------------------|
| `flatMap` | Run all concurrently | Subscribe to new stream alongside existing |
| `switchMap` | Switch to latest | Cancel previous, subscribe to new |
| `exhaustMap` | Ignore until done | Ignore new signal completely |

## Visual Comparison

Given source signals `a`, `b`, `c` arriving in sequence:

```
Source: ──a──────b──────c──▶

flatMap: ────A1─A2─B1─A3─C1─B2─C2─▶ (all run together)

switchMap: ────A1─────────C1─C2─▶ (only latest completes)

exhaustMap: ────A1─A2─A3─────D1─▶ (ignores b, c while a runs)
```

## Decision Guide

Choose based on what should happen when a new signal arrives while processing:

```
New signal arrives while inner stream is active
┌───────────────┼───────────────┐
▼ ▼ ▼
Run both? Cancel old? Ignore new?
│ │ │
▼ ▼ ▼
flatMap switchMap exhaustMap
```

### Use `flatMap` when:
- Order doesn't matter
- All operations should complete
- Example: Playing multiple sounds, logging events

### Use `switchMap` when:
- Only the latest result matters
- Previous operations are obsolete
- Example: Search autocomplete, preview on hover

### Use `exhaustMap` when:
- Current operation must complete first
- Duplicate requests should be prevented
- Example: Form submission, drag session

## Example: Same Scenario, Different Operators

Fetching user details on hover:

```typescript
// flatMap: All hovers trigger fetches (may show stale data)
hover$.pipe(flatMap((s) => fetchUser(s.value.userId)))

// switchMap: Only shows latest hover's user (recommended)
hover$.pipe(switchMap((s) => fetchUser(s.value.userId)))

// exhaustMap: Ignores hovers until current fetch completes
hover$.pipe(exhaustMap((s) => fetchUser(s.value.userId)))
```

## Relation to Monad

These operators implement the "bind" operation from monad theory, enabling composition of effectful computations. `flatMap` satisfies the three monad laws:

1. **Left Identity**: `pure(a).flatMap(f) === f(a)`
2. **Right Identity**: `m.flatMap(pure) === m`
3. **Associativity**: `m.flatMap(f).flatMap(g) === m.flatMap(x => f(x).flatMap(g))`

This mathematical foundation ensures predictable composition behavior.
90 changes: 90 additions & 0 deletions docs/src/content/operator-api/exhaust-map.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# `exhaustMap`

Maps each signal to a Stream, ignoring new signals while an inner stream is active.

## Signature

```typescript
function exhaustMap<T extends Signal, R extends Signal>(
project: (signal: T, index: number) => Stream<R>
): Operator<T, R>
```

## Example

```typescript
import { domEvent } from "cereb";
import { exhaustMap } from "cereb/operators";

domEvent(submitButton, "click")
.pipe(
exhaustMap(() => submitForm())
)
.on((result) => {
showConfirmation(result.value);
});
```

## How It Works

When a signal arrives and no inner stream is active, `exhaustMap` creates and subscribes to a new inner stream. While that inner stream is active, all incoming signals are ignored. Once the inner stream completes, the next signal will be processed.

```
Source: ──a────b────c────d──▶
\ ✕ ✕ \
Inner: ─A1─A2─▶ ─D1─▶

Output: ────A1─A2─────────D1─▶
```

## Use Cases

### Prevent Double Submission

Ignore additional clicks while form is being submitted:

```typescript
domEvent(submitButton, "click")
.pipe(
exhaustMap(() => submitFormToServer())
)
.on((response) => {
if (response.value.success) {
showSuccess();
}
});
```

### Drag Session Locking

Process only one drag session at a time:

```typescript
singlePointer(element)
.pipe(
session(),
exhaustMap((signal) => handleDragSession(signal))
)
.on((result) => {
applyTransform(result.value);
});
```

### Rate-Limited Operations

Process expensive operations one at a time:

```typescript
keyboard(window)
.pipe(
filter((s) => s.value.key === "Enter"),
exhaustMap(() => performExpensiveCalculation())
)
.on((result) => {
displayResult(result.value);
});
```

## Note

Use `exhaustMap` when you want to complete the current operation before handling new signals. For canceling previous operations, use `switchMap`. For concurrent operations, use `flatMap`.
74 changes: 74 additions & 0 deletions docs/src/content/operator-api/flat-map.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# `flatMap`

Maps each signal to a Stream and flattens all inner streams concurrently.

## Signature

```typescript
function flatMap<T extends Signal, R extends Signal>(
project: (signal: T, index: number) => Stream<R>
): Operator<T, R>
```

Also exported as `mergeMap`.

## Example

```typescript
import { singlePointer } from "cereb";
import { flatMap, session } from "cereb/operators";

singlePointer(element)
.pipe(
session(),
flatMap((signal) => fetchRelatedData(signal.value.x, signal.value.y))
)
.on((result) => {
console.log(result.value);
});
```

## How It Works

When a signal arrives, `flatMap` creates a new inner stream and subscribes to it immediately—without canceling any existing inner streams. All inner streams run concurrently, and their signals are merged into the output stream.

```
Source: ──a──────b──────c──▶
\ \ \
Inner: ─A1─A2─▶ ─B1─▶ ─C1─C2─▶

Output: ────A1─A2──B1───C1─C2─▶
```

## Use Cases

### Parallel Data Fetching

Fetch data for each pointer session without waiting for previous requests:

```typescript
singlePointer(element)
.pipe(
session(),
flatMap((signal) => fetchLocationData(signal.value.x, signal.value.y))
)
.on((data) => {
renderMarker(data.value);
});
```

### Concurrent Sound Effects

Play multiple sounds simultaneously:

```typescript
keyboard(window)
.pipe(
flatMap((signal) => playSound(signal.value.key))
)
.on(() => {});
```

## Note

If you need to cancel previous operations when new signals arrive, use `switchMap` instead. If you need to ignore new signals until the current operation completes, use `exhaustMap`.
86 changes: 86 additions & 0 deletions docs/src/content/operator-api/switch-map.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# `switchMap`

Maps each signal to a Stream and switches to the new inner stream, canceling the previous one.

## Signature

```typescript
function switchMap<T extends Signal, R extends Signal>(
project: (signal: T, index: number) => Stream<R>
): Operator<T, R>
```

## Example

```typescript
import { singlePointer } from "cereb";
import { switchMap } from "cereb/operators";

singlePointer(element)
.pipe(
switchMap((signal) => fetchSuggestions(signal.value.x))
)
.on((result) => {
showSuggestions(result.value);
});
```

## How It Works

When a signal arrives, `switchMap` unsubscribes from any active inner stream before subscribing to the new one. Only the most recent inner stream is active at any time, automatically preventing race conditions.

```
Source: ──a──────b──────c──▶
\ ✕ \
Inner: ─A1─A2─╳ ─B1─╳ ─C1─C2─▶

Output: ────A1─────────────C1─C2─▶
```

## Use Cases

### Search Autocomplete

Only show results for the latest search query:

```typescript
keyboard(searchInput)
.pipe(
debounce(300),
switchMap((signal) => searchAPI(signal.value.key))
)
.on((results) => {
renderDropdown(results.value);
});
```

### Latest Selection Preview

When user hovers over items, show preview for the current item only:

```typescript
singlePointer(listElement)
.pipe(
switchMap((signal) => fetchPreview(getItemAt(signal.value.x, signal.value.y)))
)
.on((preview) => {
showPreview(preview.value);
});
```

### Cancel Previous Animations

Start new animation, canceling any in-progress one:

```typescript
keyboard(window)
.pipe(
filter((s) => s.value.key === "ArrowRight"),
switchMap(() => animateSlide("next"))
)
.on(() => {});
```

## Note

Use `switchMap` when you only care about the latest operation. For concurrent operations, use `flatMap`. To ignore new signals until current completes, use `exhaustMap`.
Loading