Search code examples
javascriptfrprxjsbacon.jskefir.js

Idiomatic way to mutate a property with multiple events using Kefir


What's the idiomatic way to create a property in Kefir that changes in response to multiple event types?

In my project, I started off using rxjs for a FRP-style application. In this application, I wanted to subscribe to state that changed in response to multiple variables. This is how I sort of got it working:

const subject = new BehaviorSubject([]);

addEvents
  .withLatestFrom(subject, (newItem, items) => items.concat(newItem))
  .subscribe(subject);

removeEvents
  .withLatestFrom(subject, (item, items) => {
    return items.filter(i => i !== item);
  })
  .subscribe(subject);

I could tell that this was probably not the best practice; it does not seem idiomatic, and I also just figured out that subscribing an observer to multiple sources isn't really correct.

I decided for many reasons to try a different library than RxJs, and am now evaluating Kefir, which has great documentation and supposedly better performance. But I'm finding it even more difficult to determine how to do what I'd like, short of ugly hacks where I'd have to inspect event types:

kefir
  .merge(addEvents, removeEvents)
  .scan(
    (items, event) => {
      switch (event.type) {
        case 'add': return items.concat(event.item);
        case 'remove': return items.filter(i => i !== event.item);
      }
    },
    [])
  .toProperty();

I'd really prefer to not have to use inelegant techniques like big conditional blocks for a bunch of event types in order to create a stream of changes.

I don't plan on using Bacon.js, but I do see it has exactly what I need:

Bacon.update([],
  [addEvents],    (items, evt) => items.concat(evt.item),
  [removeEvents], (items, evt) => items.filter(i => i !== evt.item));

Is there a natural way of doing this sort of thing with Kefir with its standard operators, or is this something I'd end up having to implement myself?


Solution

  • I figured out this approach which I'm not thrilled with, but at least the code is pretty clean:

    const items = function () {
      let items = [];  
      return kefir
        .merge([
          addEvents.map(item => items = items.concat(item)),
          removeEvents.map(item => items = items.filter(i => i !== item))
        ])
        .toProperty(() => items);
    }();
    

    What I dislike about this is the fact that it mutates state, but since JavaScript is single-threaded and I'm hiding that state, maybe it's not so bad.

    Here it is as a utility function:

    import kefir from 'kefir';
    
    export default function dynamicValue(initValue, ...args) {
      let value = initValue;
      let streams = [];
      while (args.length) {
        let [source, xform, ...remaining] = args;
        streams.push(source.map(v => value = xform(value, v)));
        args = remaining;
      }
    
      return kefir.merge(streams).toProperty(() => value);
    }
    

    ...used like this:

    dynamicValue([],
      addEvents, (items, item) => items.concat(item),
      removeEvents, (items, item) => items.filter(i => i !== item));
    

    Update:

    Figured out a different way to implement dynamicValue using map, merge, and scan without needing to mutate a variable:

    import kefir from 'kefir';
    
    export default function transform(initValue, ...args) {
      let mutations = [];
      while (args.length) {
        let [source, calculateNewValue, ...newArgs] = args;
        mutations.push(source.map(e => ({ event: e, mutation: calculateNewValue })));
        args = newArgs;
      }
    
      return kefir
        .merge(mutations)
        .scan((prev, { event, mutation }) => mutation(prev, event), initValue);
    }
    

    Basically, it pairs each event with the mutation function, merges these pairs, then scans over them, applying the mutation function to the original value and event. I'm not sure if it's really better, but it does seem more "functional."