Observables are Elephants

Vesa Karvonen

Premise

Observables are a rather flexible mechanism and allow for many different styles of programming with very different properties.

It seems that often people learn only a style or two and have misconceptions about the properties of observables.

The intended audience for this is programmers recently introduced to Observables.

I assure you that it was not all immediately obvious to me when I first learned of observables.

Goals

Give a sense of the depth and breadth of programming with observables...

...by briefly discussing a few different ways to program with observables.

No claim is made regarding the completeness of this treatment!

Observables?

Interfaces

interface Observable<T> {
  subscribe(observer: Observer<T>);
  unsubscribe(observer: Observer<T>);
}
interface Observer<T> {
  onValue(value: T): void;
  onError(error: any): void;
  onEnd(): void;
}

Combinators

  • constant(value)
  • error(value)
  • o1.flatMap(v => o2)
  • combine([...os])
  • merge([...os])
  • o.scan((s, x) => s1, s0)
  • o.debounce(ms)
  • o.skipDuplicates(equals)
  • ...and more

Why observables?

Asynchronous operations, time, state are very difficult

Observables can make such code more declarative

Observables are highly composable (4+ monads)

Asynchronous programming

What is async programming?

Like what you do with async-await

Linear thread of control with asynchronous ops

const foo = async (...xs) => {
  const r = await bar(...xs)
  return baz(r)
}
const mapAsync = async (fnA, xs) => {
  const ys = []
  for (let i = 0; i < xs.length; ++i)
    ys.push(await fnA(xs[i]))
  return ys
}

Ingredients for async programming?

  • o1.flatMap(v => o2) — to await
  • constant(v) — to introduce constants
  • Recursion — instead of loops
  • subscribe(observer) — to start
  • fromNodeCallback(fn) — interop
  • lazy(() => o) — to avoid over eagerness
const lazy = toObservable =>
  constant(undefined).flatMap(() => toObservable())

So...

const foo = (...) => lazy(() =>
  bar(...)
  .flatMap(r =>
    constant(baz(r))))
const mapObs = (fnO, xs) => lazy(() => {
  const ys = []
  const loop = i => lazy(() => {
    if (i < xs.length)
      return fnO(xs[i])
             .flatMap(y => {
               ys.push(y)
               return loop(i+1)
             })
    else
      return constant(ys)
  })
  return loop(0)
})

Why you'd want to do that?

Observables compose

Observables are cancellable

Sound familiar?

And observables can do other things beyond promises

Programming with discrete events

What are discrete events?

Something has happened or something has changed

Examples

  • Mouse clicks
  • Button presses
  • Change events

This is probably what comes to mind when you think of observables?

Ingredients for discrete events

  • merge([...os]) — gather events
  • o.scan((s, v) => s1, s0) — state
  • update(...) — generalization of scan
  • o1.flatMap(v => o2) — do async after event
  • o1.flatMapConcat(v => o2) — don't drop
  • fromEvents(target, name) — interop
  • ...and others

Examples

Great!

Much preferable to traditional use of callbacks

Powerful combinators for dealing with time

Difficulties

  • Leads to local state
    • Notification of change or event is not enough
    • Accumulate local state based on events
  • Fragile
    • Miss an event?
    • Get events in unexpected order?
    • Duplicate events?
  • Accidental complexity
    • Merging and scanning boilerplate
    • Must deal with timing and consistency

Programming with applicative stateless properties

What are properties

What something is

  • Mouse position
  • Accumulated input
  • State

Independence from time

Source of truth

Ingredients for properties

  • o.map(i => o) — derived property
  • o1.flatMapLatest(v => o2) — async derived
  • combine([...os]) — derived from many props
  • ...and others

Example

export default U.withContext(({phase}, {user_state}) => {
  const done  = US.doneCount(phase, user_state)
  const total = US.itemCount(phase, user_state)

  const percentage = U.round(U.multiply(U.divide(done, total), 100))
  const completed = U.ifte(U.equals(done, total), "completed", "")

  return <div className={U.string`progress ${completed}`}>
    <div className="progress__bar"/>
    <div className="progress__progressed"
         style={{width: U.string`${percentage}%`}}/>
    <div className="progress__label">
      <span>{done}/{total} valmis</span>
    </div>
  </div>
})

Advantages

Simple — just map

Stateless properties are RT — can be reconstructed

Time is largely out of the equations

Robust — skip, dup, ... don't matter much

Often less boilerplate

Disadvantages

Different mode of thinking

Monadic- and Direct-style programming

What styles?

Direct-style

f(x1, x2)
x + y * z
R.add(x, R.multiply(y, z))

Monadic-style

x1.flatMapLatest(x1 =>
x2.flatMapLatest(x2 =>
constant(f(x1, x2))))

Cumbersome, isn't it?

combine([x1, x2], f)

As good as it gets?

Lifting

const lift = f => (...xs) => combine(xs, f)
const fL = lift(f)
fL(x1, x2)

A prelifted utility library?

Conditionals

const ifte = (c, t, e) =>
  c.flatMapLatest(c => c ? t : e)

Examples

Advantages

Can be significantly more legible than monadic-style

Disadvantages

Need to lift combinators for legibility

Gotchas with lifting techniques — need to fully apply

More limited — you'll occasionally need monadic-style

IO

IO?

Side-effects

Asynchronous HTTP requests

In response to events or state changes

Also just async programming on backend (skipped)

Examples

Online search

Cancellable HTTP requests

Various

Cycle HTTP driver

Ingredients

  • o1.flatMapLatest(v => o2) — do async IO
  • o.debounce(ms) — avoid too many requests
  • o.throttle(ms) — avoid too many requests
  • fromPromise( ??? ) — broken, don't use
  • fromBinder(subscribe) or stream(emitter => ...) — wrap XMLHttpRequest

Limitations of Observables

Push and Pull

Observables are (typically) push-based: An observable (source) synchronously calls the observer (sink)

What if observer is not ready? What about concurrency? Locking?

Back pressure - complex with pushy-style

Pull-style puts consumer in charge

Pull-style is fundamentally different, dual

Broken abstraction

Glitches

Recursion limitations

Rendezvous

Observables are like asynchronous message passing

Rendezvous, like in CSP, is not directly supported

Questions