DEV Community

Cover image for Build a reactive split-flap display with RxJS and Rimmel.js
Dario Mannu
Dario Mannu

Posted on

Build a reactive split-flap display with RxJS and Rimmel.js

This is my take on @maxime1992's challenge for a functional-reactive split-flap display.

For a start, I've chosen to use Rimmel.js, a template engine created (disclosure: by me) with streams in mind, which you can just feed promises and observables and they will seamlessly be subscribed and sinked to the DOM, helping you get rid of the very latest bits of imperative code that still linger around in many Rx apps.

Next, @maxime1992 I want to congratulate with you for the code. I really loved its elegance and I first learnt about the switchScan operator.

For those who haven't read the challenge, and perhaps Maxime's implementation, I warmly recommend having a look before continuing.

The focus of the challenge is RxJS, so I'll focus on that, too, with a few words around the way the solution below uses Rimmel sources and sinks.

Async generators

To make things fun, I also created a new getLettersFromTo() as an async generator function, so it can stay lazy and emit at the pace we want.

export async function* getLettersFromTo(from, to) {
  const a = LETTERS.indexOf(from) +1;
  const b = LETTERS.indexOf(to);
  const l = LETTERS.length;
  const carry = b < a ? l : 0;

  for (var i = a; i <= b +carry; i++) {
    yield LETTERS[i % l];
    await delay(DELAY_AMOUNT);
  }
}
Enter fullscreen mode Exit fullscreen mode

Actually, I didn't make it just for fun, but to solve another issue I was having, then I realised it enabled me to simplify the utils.js by a few functions, so I kept it, although I might need to retire it if it doesn't qualify for the FRP requirement set for the challenge.
The focus is not so much on this function, though.

Push and pull models together

RxJS can beautifully turn async generators into observables, giving us some sort of lazy-push-model based stream.

import { from as ObservableFrom } from 'rxjs';

const sequence = ([prev, next]) =>
  ObservableFrom(getLettersFromTo(prev, next))
Enter fullscreen mode Exit fullscreen mode

Personal note: I can't make myself like that from(), the way it is. It used to be called Observable.from(), which made sense to my ears and I'm missing it a lot, so importing it as ObservableFrom helps reminding of the good old Rx5 days. :)

So, the function above essentially returns an observable sequence of all the letters the split-flap display needs to loop through.

The main stream

Following is input$$, a Subject we'll feed the data we want the board to display from the UI.

const input$$ = new Subject().pipe(
  debounceTime(300),
  map(e => inputToBoardLetters(e.target.value)),
  share(),
  startWith(BASE),
);
Enter fullscreen mode Exit fullscreen mode

Quite simple so far, it will just take an input string, convert it to uppercase letters and make the stream "shareable" for reduced processing.

One stream per letter

Then we have a stream for each letter on the board. Yes, one observable each, we'll come back to that later.

const nextLetter = index => input$$.pipe(
  map(str => str[index]),
  distinctUntilChanged(),
  pairwise(),
  switchMap(sequence),
);
Enter fullscreen mode Exit fullscreen mode

This one takes the whole string to display, picks a letter we specify by its index, and returns, through switchMap the sequence of characters to display, which will be provided by the sequence function above.

We're using pairwise, which returns the previous and the next value every time. That helps generating the sequence in terms of the current letter on the board and the next one to show.

switchScan vs switchMap

This I think is the most interesting difference from Maxime's solution. He used switchScan, I've used switchMap.

The idea behind switchScan is that you can progressively change your already-in-progress transformation. Like you start "reducing" the stream in a way, then something happens (like the user gives a new input to render), then you can adapt to that change accordingly, whilst the whole board is flipping, starting from its current state, but not starting over altogether.

That's certainly the most fascinating way to solve the problem to me, but I thought I'd still try another way, and realised swtichMap could do the trick, as well.

UI

The UI is made by two Rimmel components. They are very similar to React's function components, except you can use tagged templates and just seamlessly assign observables as either sources or sinks to HTML attributes.
Rimmel will call fromEvent, next() or subscribe() accordingly.

The letter component

const board = initial => Object.keys(initial).map(i => render`
  <span class="letter">${nextLetter(i)}</span>
`).join('')
Enter fullscreen mode Exit fullscreen mode

This component loops through each letter and renders a span tag, in which we sink the observable created by nextLetter.

Performance?

Back to the reason why I've created one observable sequence per letter on the board. That was to keep the functional-reactive style and avoid having to create an imperative sink to update each one.
In an imperative fashion, you loop through your letters and set them in the DOM:

for(i=0;i<letters.length;i++) {
  some.dom.element[i].innerHTML = letters[i];
}
Enter fullscreen mode Exit fullscreen mode

In a more functional-reactive style, each letter component in the DOM can be declared as a sink for a corresponding stream on its own.

  <span class="letter">${nextLetter(i)}</span>
Enter fullscreen mode Exit fullscreen mode

There will be performance considerations in this case, of course. The imperative style will beat anything else if raw speed is at stake, although if we had a (n extremely) huge board to update at very high frequency, that would break the framerate budget, so I would rather make use of Schedulers in order to keep the FRP style, all its benefits, and high performance.

The main template

And finally we have our main template, featuring a text box input$$ will be sourcing data from.

const page = () => render`
  Input String: <input oninput="${input$$}" onmount="${input$$}" value="${INITIAL_STRING}">

  <div class="letters">
    ${board(BASE)}
  </div>

  <div>Bye</div>
`;

document.body.innerHTML = page();
Enter fullscreen mode Exit fullscreen mode

Essentially, whenever oninput or the custom onmount events fire, they will call input$$.next(event) behind the scenes.

Conclusion

@maxime1992 I loved this challenge, thanks for the fun, and hope others will join, too!

The full code is available here: https://stackblitz.com/edit/rxjs-xubnby

Top comments (1)

Collapse
 
maxime1992 profile image
Maxime

Hey Dario, thanks for taking up the challenge and sharing your own idea! Looks like you had as much fun as I had :D!

For of all I must say that I absolutely love the idea of the generator. Quite smart to keep it lazy and I think it reads in an easier way with the switchMap and the generator. You definitely inspired me and while I never used generators in streams so far, I'm sure it'll happen a lot more often now!

About your library, love it as well. Ofc for production grade apps I usually use Angular, but when I do demos in pure JS I'm always annoyed by the template side of things. This is a really good in between and I love the fact that it manages the observables directly.

Have my sub and I hope to see many other interesting posts from you like this one!

Congrats for the challenge!