A short, beginner-friendly walkthrough for building an infinite scroll reader that loads articles from dev.to in the Stream-Oriented paradigm (SP).
This is a great way to learn how streams can replace traditional event handlers, callbacks, and state variables with something cleaner and easier to reason about. You can run the full example here: https://stackblitz.com/edit/dev-to-infinite-scroll-component
Why streams?
Most UI frameworks revolve around components, lifecycle hooks, and state updates. Rimmel.js is a stream-oriented UI library that takes a different approach: everything (user actions, async data, DOM updates) is treated as a stream of values that flow through transformations. Instead of thinking in terms of components "reacting" to events, you just describe how data moves. This eliminates glue code and makes your app easier to extend, debug, and test.
In SP style, logic is expressed as data pipelines rather than nested callbacks and shared state. You get a clear separation between producers (events), transformers (logic), and consumers (UI updates). The result is code that feels simple but scales elegantly.
Step 1: Imports and a tiny Event Adapter
Let’s start at the top of main.ts
. We import RxJS tools and Rimmel helpers. RxJS gives us stream operators like scan
and concatMap
, while Rimmel lets us connect those streams directly to the DOM.
Next comes a small but powerful pattern called an Event Adapter. This turns browser events into a predictable stream of data. We define one called Next
that converts scroll events into sequential page numbers.
const Next = inputPipe<Event, number>(
scan(x => x + 1, 0),
);
Before diving deeper, it’s important to understand what inputPipe
really is. Unlike RxJS’s .pipe()
method, which applies transformations on the way out of an Observable, inputPipe
is a Rimmel utility that sets up a pipeline on the way in. It feeds a target stream with processed data as events occur. This makes it ideal for creating Event Adapters: small, composable bridges that turn DOM events into structured stream inputs.
Why scan
?
scan
is an RxJS operator that keeps a running state over time, similar to reduce
, but for continuous streams. Here, it starts at 0 and increments each time the adapter receives an event. Instead of raw DOM events, we now get a stream of clean, numeric values: 0, 1, 2, 3… which represent page numbers to fetch. It’s a simple and declarative way to generate sequential data without mutable counters.
Unlike traditional frameworks that rely on internal state or lifecycle methods, here we simply transform data as it flows. No side effects, no manual bookkeeping.
Step 2: A simple article template
The articleTemplate
turns dev.to API results into HTML using Rimmel’s rml
syntax. It’s a pure function: data in, markup out.
const articleTemplate = ({ url, title, tag_list, description, social_image, user }) => rml`
<article>
<h3><a href="${url}">${title}</a></h3>
<img class="social-image" src="${social_image}">
<p>${description}</p>
<div class="notes">
Tags: <span>${tag_list.join(', ')}</span>
<div>Posted by <a href="https://dev.to/${user?.username}">${user?.name}</a></div>
</div>
</article>
`;
Step 3: The page stream – fetching and rendering
Now we define a stream named page
. It receives numbers (our page indexes) and emits HTML as output. Every time it gets a new number, it fetches that page from dev.to and turns the results into HTML.
const page = new Subject<number>().pipe(
concatMap(page => fetch(`https://dev.to/api/articles?page=${page}&per_page=10`)),
concatMap(response => response.json()),
map(posts => posts.map(articleTemplate).join('')),
) as Observer<number> & Observable<HTMLString>;
Here’s what happens step by step:
-
Subject
is both an event receiver (Observer) and data emitter (Observable). -
concatMap
ensures requests happen one after another, maintaining order. If page 3 starts before page 2 finishes, it waits, keeping results sequential. - The second
concatMap
unwraps the fetchResponse
into JSON. -
map
transforms the JSON array into HTML via our template.
Why concatMap
?
concatMap
is crucial for ordered async work. It queues up asynchronous operations (like fetches) and executes them in sequence. If you used mergeMap
or switchMap
instead, requests could overlap or cancel each other, breaking pagination. concatMap
ensures smooth, predictable flow (page 1, then page 2, then page 3) just as users expect when scrolling.
This pipeline describes what to do with input numbers, not how or when. The order and timing are handled by the stream itself.
Step 4: Connecting the UI
The app template includes a <main>
element and uses AppendHTML
: a Rimmel sink that appends whatever HTML the stream emits into the DOM.
<main>
${AppendHTML(page)}
</main>
You don’t manually touch the DOM. When new HTML arrives, Rimmel updates it automatically.
Step 5: The in-view marker that triggers new pages
To make it scroll automatically, we use a custom element called <inview-marker>
. It emits an event when it enters the viewport. We connect that event to our Next
adapter so that every time the marker comes into view, the next page number flows through the pipeline.
<inview-marker oninview="${Next(page)}"></inview-marker>
That’s it: no scroll listeners, timers, or counters. Just streams.
Step 6: The custom element logic
In lib/in-view-marker.ts
, we register the element and use the browser’s IntersectionObserver
API. When the element appears, it emits an inview
event.
RegisterElement('inview-marker', ({ oninview }) => {
const emit = callable(oninview);
const load = ({ target }) => {
new IntersectionObserver((entries) => {
entries.forEach(e => e.isIntersecting && emit(new Event('inview')));
}).observe(target);
};
return rml`<marker rml:onmount="${load}"></marker>`;
});
This small bridge connects browser visibility events to your reactive stream world.
Step 7: Bootstrapping the app
Finally, the app mounts itself by setting its root element’s innerHTML
to the Rimmel template output.
document.getElementById('app').innerHTML = App();
Everything is reactive from that point forward. The first page loads, the marker enters view, and new pages stream in.
Why this pattern improves code quality
There’s no tangled state or complex lifecycle management. Each piece does one thing:
- The adapter converts events into numbers
- The stream defines how data flows
- The sink updates the DOM
- The in-view marker triggers when needed
Because every link in the chain is declarative, the behaviour is transparent and easy to debug. If you ever want to change behaviour—like preloading or adding filters—you modify one stream operator instead of rewriting your UI logic.
Run it live
Try the complete demo here: https://stackblitz.com/edit/dev-to-infinite-scroll-component
Scroll down and watch new posts load automatically, one clean stream at a time.
If you appreciate the clean, modern approach of the stream-oriented paradigm, join a growing community of forward-thinking developers and give the project a ⭐⭐⭐⭐⭐ on GitHub to show your support.
Top comments (0)