If you want to improve the overall quality of your code, you may want to keep your data models neatly decoupled from the underlying views.
Functional-Reactive frameworks or UI libraries that have full support for Observables enable you to define your models as Observable streams (e.g.: data in, data out) in addition to a little-known design pattern which is the event adapter.
Event adapters help you map any source events (E.G.: the DOM's MouseEvent
, PointerEvent
, KeyboardEvent
, etc) to the format actually used by your data models, so they'll be freed from this conversion task and be ultimately decoupled from the UI.
Observable-aware UI libraries such as Rimmel.js make it simple to connect such a stream to the DOM:
import { rml } from 'rimmel';
const component = () => {
const total = new Subject().pipe(
map(x => doSomethingWith(x)),
);
return rml`
<button onclick="${stream}">click me</button>
<div id="display">${stream}</div>
`;
}
The binding is trivial: Rimmel connects click events coming from the button directly into your observable stream, which will receive instances of PointerEvent
every time the button is clicked.
So far so good. What if your stream needs to take data from multiple sources, and behave differently depending on each?
Let's create a simple counter with an increment and a decrement button, each adding or subtracting one from it.
import { scan } from 'rxjs';
import { rml } from 'rimmel';
const component = () => {
const total = new BehaviorSubject(0).pipe(
scan((old, new) => old+new, 0),
);
return rml`
<button onclick="${() => total.next(1)}">inc</button>
<button onclick="${() => total.next(-1)}">dec</button>
<div>${total}</div>
`;
}
This works, but the template part is including some logic, which is an anti-pattern. Ideally we should strive to have logic-less templates to maximise testability overall.
So, with Rimmel 1.2 you have a new feature, Event Adapters, which help exactly with that. They help you map DOM events into what your model needs, so you can keep logic perfectly separate from the template. Here's how it works.
import { map, scan } from 'rxjs';
import { rml, reversePipe } from 'rimmel';
const Inc = reversePipe(map(() => 1));
const Dec = reversePipe(map(() => -1));
const component = () => {
const total = new BehaviorSubject(0).pipe(
scan((old, new) => old+new, 0),
);
return rml`
<button onclick="${Inc(total)}">inc</button>
<button onclick="${Dec(total)}">dec</button>
<div>${total}</div>
`;
};
reversePipe
is the innovative addition here: a pipeline creation tool that works opposite to the pipe()
function in RxJS. Whilst the latter applies transformations to the output of a stream, reversePipe()
applies them at the input.
This way you're sure your main stream Subject/BehaviorSubject/Observer/EventListener always get data in the formats you want and you keep your adapter as a separate concern.
You can use any RxJS operator in your reverse pipelines. Do you only want to filter out certain events, like when the user hits Enter, rather than any other key? Just use the filter operator
:
import { Subject, filter, map } from 'rxjs';
import { rml, inputPipe } from 'rimmel';
const UpperOnEnter = inputPipe(
filter((e: Event) => e.key == 'Enter'),
map((e: Event) => e.target.value.toUpperCase()),
);
const Component = () => {
const state = new Subject();
return rml`
Type some text and hit Enter<br>
<input onkeydown="${UpperOnEnter(state)}">
<div>${state}</div>
`;
};
With regards to unit testing this is a tiny but useful addition that will make tests simpler and more efficient.
Check out event adapters in action on this Stackblitz