How to Not Build a React App (Part VII)

Wherein it feels like we are starting to reimplement RxJS

Back to: Part I

Motivation

There’s a couple of (perhaps minor) things we can clean up from the PR on effects. Take a closer look at how effects are triggered:

subscribe((state) => {
  let newState = state;
  for (const effect of effects) {
    newState = effect(newState);
  }
  if (newState !== state) {
    setState(newState);
  }
});

The logic for most effects is: “Is the UI in a state where something needs to happen? If so, do it and update the state so we know the effect is in progress.” Most of the time they should do nothing and return the same state. The condition at the end (if (newState !== state)) is a bit awkward. If you think of subscribe as providing a stream of States we never will want to provide the exact same state twice. It seems nicer (to me anyway) to somehow modify subscribe so that it won’t produce the same value twice in a row. The code we end up with with look more like:

subscribe
    .with(skipDuplicates())
    ((state => {...}))

The other issue is a bit more subtle. When we call setState, that new state re-enters the code above, and all the effects run again. On one hand, this is expected as that block of code should get every state change. However, there is some danger of the stack overflowing because those effects run synchronously. I’d like to have subscribe produce at most one value ‘per tick’. (This will also be useful when we have a UI to render: We don’t really want to render state changes that happen faster than the browser can render the UI.)

Comparison with RxJS

RxJS is (was?) a popular library for reactive programming. Their observer is similar to our Consumer.

type Consumer<T> = (t: T) => void;

observer has three methods: next, error, and complete.

const observer = {
  next: (state: State) => console.log('Observer got a next value: ' + state),
  error: err => console.error('Observer got an error: ' + err),
  complete: () => console.log('Observer got a complete notification'),
};

next is precisely Consumer<State>, and if we were using RxJS that’s the method that would get the stream of all the states. We don’t need complete because our stream of States is infinite; it continually produces values as long as the UI exists. We might have errors but those will be modeled inside of our State. For example, our DisconnectedState optionally has a time of failure. Finally, since we just have the next method there’s no need to even have a method and we just use Consumer<State> directly.

Operators

RxJS has pipeable operators, and it looks like we end up in more or less the same place. But back to our project:

ConsumerTransformers

This is perhaps a subtle point: Above, I said I wanted to transform subscribe, and we pass a Consumer<State> to subscribe as an argument.

We will instead begin by transforming Consumers:

type ConsumerTransformer<T, S = T> = (
  mapper: Consumer<T>
) => Consumer<S>;

Once we have that type it’s easy to implement the transformations I need:

const skipDuplicates =
  <T>(): ConsumerTransformer<T> =>
  (consumer) => {
    let lastValue: T;
    return (t: T) => {
      if (t !== lastValue) {
        lastValue = t;
        consumer(t);
      }
    };
  };

Note that skipDuplicates is a function that returns a ConsumerTransformer<T>, this is just to deal with the generic <T>. (You can’t have a value that is generic).

I split the next transformation in two: We don’t want to produce value “too fast”, for the effects I’m going to use queueMicrotask, but for the rendering of the UI I will use requestAnimationFrame. I’ll be honest that I don’t quite understand the intricacies of these two functions, but it seems like a good place to start.

type Callback = () => void;
type Scheduler = Consumer<Callback>;

const makeThrottler =
  <T>(scheduleWork: Scheduler): ConsumerTransformer<T> =>
  (consumer) => {
    let workScheduled = false;
    let latestValue: T;
    return (t: T) => {
      latestValue = t;
      if (!workScheduled) {
        scheduleWork(() => {
          consumer(latestValue);
          workScheduled = false;
        });
      }
    };
  };

export const perTick = <T>(): ConsumerTransformer<T> =>
  makeThrottler(queueMicrotask);
export const perAnimationFrame = <T>(): ConsumerTransformer<T> =>
  makeThrottler(requestAnimationFrame);

Futzing with subscribe

Now we come back to the subtle point: I’d rather transform subscribe directly than transform the “consumers”. Of course the code above transforms consumers. It took me (at least) 3 tries to get this right.

Ugly Attempt 1

This is in the PR. The code looks like:

const subscribeEffects = pull(
  compose(skipDuplicates<State>(), perTick<State>())
)(subscribe);

subscribeEffects((state) => {
  setState(effects.reduce((newState, effect) => effect(newState), state));
});

On one hand, pull and compose are fairly natural things you’d want to do with functions. Most people are familiar with function composition, pullbacks are probably less so. On the other hand, the code is quite ugly:

export const pull =
<A, B>(f: (a: A) => B) =>
  <C>(g: (b: B) => C) =>
  (a: A) =>
    g(f(a));
export const compose =
  <A, B, C>(g: (b: B) => C, f: (a: A) => B) =>
  (a: A) =>
    g(f(a));

That said, the idea behind a pullback (or “precomposition” if you prefer), is important for the problem at hand. Anytime you know how to transform the argument to a function, you can instead transform the function itself. This is precisely what we want.

Better Attempt 2

Javascript allows functions to have properties. We basically move pull from a stand alone function to a property we can attach to subscribe. PR

First we define a ConfigurableFunction:

type ConfigurableFunction<A, B> = {
  with: <C>(transformer: (c: C) => A) => ConfigurableFunction<C, B>;
  (a: A): B;
};

I’m not sure that’s the best name, but with precomposes this with the transformer function passed in.

The way it’s used looks better:

const subscribeEffects = subscribe.with(skipDuplicates()).with(perTick());

We, of course, need to add the with property to our functions, and also remember to attach it to the function it returns (so we can chain multiple .with calls):

export const makeConfigurable = <A, B>(
  f: (a: A) => B
): ConfigurableFunction<A, B> => Object.assign(f, { with: withProperty });

function withProperty<A, B, C>(
  this: (a: A) => B,
  transformer: (c: C) => A
): ConfigurableFunction<C, B> {
  return makeConfigurable((c: C) => this(transformer(c)));
}

Attempt 3 won’t be much different; I need to futz with where the generics show up. We will see that in the next article on coeffects.