Recreating a classic FRP tutorial with Svelte and RxJS
Learn how I recreated and extended a classic functional reactive programming tutorial without using external state
The best way to learn something is to recreate it from scratch. Actually type out the code. This way you brain will have time to process and understand while your fingers move.
I've decided to recreate a classic functional reactive programming tutorial from 2014 with the most recent version of RxJS (currently at 6.5) and tiny reactive web framework called Svelte.js.
This is the tutorial in question that we are going to recreate and then also extend.
The introduction to Reactive Programming you've been missing.
What will you learn?#
- How to model your business domain using RxJS
- How to consume RxJS streams in Svelte
- How to combine different RxJS streams
- How to think and solve problems with RxJS streams
- Why Svelte is a good companion to RxJS
Why Svelte?#
Honestly, the frontend framework doesn't really matter for these examples, but I chose Svelte, because I really like it. The main logic (business domain) lives outside Svelte and I often use Svelte just as a thin view layer.
Actually, I realized that I just lied. Another, and very important factor, I chose Svelte for is for its reactive stores. RxJS is a perfect fit for Svelte because you can use RxJS observables as Svelte stores right out of the box. Well, almost. You can use them as Svelte's readable stores. To use RxJS as writable stores you have to do a pinch of monkey patching.
I wrote up an introduction about Svelte and RxJS a while ago - If Svelte and RxJS had a baby. You should read it before continuing, so you understand how they two fit together.
Disclaimer#
You should have basic knowledge of RxJS to get something out of this article. If you want to learn RxJS, google it. Internet is littered with thousands of tutorials that will teach you how to build basic countdown timers and use document click events. All the same, literally just rewrites of the examples from the main RxJS documentation site.
What is RxJS?#
Simple. It's a big box of full of Lego pieces with no instructions that will keep you occupied for days, and not in a happy way. But when you finally come out, you come out stronger and smarter. But not necessary better looking. Sorry.
Jokes aside, if you ask me, it's a paradigm shift, a mind rape. If you really want to dive in, make sure to stock up on pain killers, because your head will hurt. Mine did.
Ok ok, on a serious note, RxJS will allow you to solve problems in a declarative way, instead of imperative (go and read this), with the help of functional reactive (read async, pull vs push) programming.
Everything is a stream and almost everything can be solved with streams.
And since most things in Javascript land are async, RxJS is a good fit for most things. But not all, of course.
With the air now cleared, why should you learn RxJS?
- You will start thinking about your problems in a different way
- You will write less code and instead do more thinking
- Your code will contain less bugs
- You friends will think you are smarter than you actually are
Are you ready? Have you decided to take the plunge? Cool. Let's do this!
Let's get dirty#
The author of the original tutorial, André Staltz, does a really great job of explaining how to think in RxJS streams. The code examples are written in an old version of RxJS (the article is from 2014) and it would be very easy and not a real challenge to just port the code to the most recent version of RxJS.
I wanted to do it a bit differently. I wanted to recreate the full functionality using my own interpretation of the problem, and instead using the code examples only as my guide slash cheat sheet in case I got stuck on the way.
The problem
We want to build a small widget that displays a list of Github users. We should be able to refresh the whole list and also be able to refresh each individual user suggestion.
What do we want to accomplish?
- We want to display a list of three random Github users to follow
- We should be able to refresh the whole list and get three new users
- We should be able to refresh (get a new suggestion) each individual user in the list
Seems doable.
How should we do it?
- Fetch users from the Github API and display three of them
- When clicking the 'refresh' button, we need to re-fetch the list of users and display three new ones
- When clicking the 'x' button on individual user, we need to replace the user with a new suggestion
- Each user row should show user's profile picture, username that is a link to hers Github profile page
Looks like we have a rough implementation plan. A loose one, but still a plan. Let's get cranking.
Setting up the project#
We will use Snowpack bundler for this project. Why? Because it's awesome and fast!
$ npx create-snowpack-app svelte-frp-tutorial --template @snowpack/app-template-svelte
$ cd svelte-ftp-tutorial
$ npm add -D rxjs && npm start
When started, Snowpack should automatically open our project's main page in the browser.
Now, create new users.ts
file in the src
directory.
This is the file where we will be working in mostly and yes, it's in Typescript. Snowpack has built-in support for Typescript out of the box.
Fetching data with RxJS#
Let's think about the first part of our implementation plan, fetching users from Github, and how we can solve it with the help of RxJS.
For API calls RxJS has both fetch and ajax support. We will use ajax and start small.
// users.ts
import { ajax } from 'rxjs/ajax';
const usersEndpoint = 'https://api.github.com/users';
const users = ajax.getJSON(usersEndpoint);
export { users };
Replace App.svelte
file with this code.
<!-- App.svelte -->
<script>
// import our RxJS stream from users.ts
import { users } from './users';
</script>
<!-- Remember that I told that we can use streams as Svelte stores? -->
<!-- We just have to prefix a stream with $. Yes, it's that simple -->
<pre>{JSON.stringify($users, null, 2)}</pre>
When you start the app you should see a long JSON array with Github users.
[
{
"login": "mojombo",
"id": 1,
"node_id": "MDQ6VXNlcjE=",
"avatar_url": "https://avatars0.githubusercontent.com/u/1?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/mojombo",
"html_url": "https://github.com/mojombo",
"followers_url": "https://api.github.com/users/mojombo/followers",
"following_url": "https://api.github.com/users/mojombo/following{/other_user}",
"gists_url": "https://api.github.com/users/mojombo/gists{/gist_id}",
"starred_url": "https://api.github.com/users/mojombo/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/mojombo/subscriptions",
"organizations_url": "https://api.github.com/users/mojombo/orgs",
"repos_url": "https://api.github.com/users/mojombo/repos",
"events_url": "https://api.github.com/users/mojombo/events{/privacy}",
"received_events_url": "https://api.github.com/users/mojombo/received_events",
"type": "User",
"site_admin": false
},
{
"login": "defunkt",
"id": 2,
"node_id": "MDQ6VXNlcjI=",
"avatar_url": "https://avatars0.githubusercontent.com/u/2?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/defunkt",
"html_url": "https://github.com/defunkt",
"followers_url": "https://api.github.com/users/defunkt/followers",
"following_url": "https://api.github.com/users/defunkt/following{/other_user}",
"gists_url": "https://api.github.com/users/defunkt/gists{/gist_id}",
"starred_url": "https://api.github.com/users/defunkt/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/defunkt/subscriptions",
"organizations_url": "https://api.github.com/users/defunkt/orgs",
"repos_url": "https://api.github.com/users/defunkt/repos",
"events_url": "https://api.github.com/users/defunkt/events{/privacy}",
"received_events_url": "https://api.github.com/users/defunkt/received_events",
"type": "User",
"site_admin": false
},
...
]
Sweet! It works!
The problem is that the users are returned to us sequentially with Github founders being first ones in the list. We need to introduce randomness. It's possible to achieve by appending a since
query parameter to the URL. That will return users starting at time when they created their Github account.
// users.ts
import { ajax } from 'rxjs/ajax';
const usersEndpoint = 'https://api.github.com/users';
const randomOffset = () => Math.floor(Math.random() * 100000);
const users = ajax.getJSON(`${usersEndpoint}?since=${randomOffset()}`);
export { users };
Much better.
Important note!
If you call Github's API too often they will throttle you. What you can do instead is to create a Github auth token and use that in your API calls. We are jumping a head of time a bit, but this is how you can solve it.
// users.ts
import { map } from 'rxjs/operators';
import { ajax } from 'rxjs/ajax';
const usersEndpoint = 'https://api.github.com/users';
const randomOffset = () => Math.floor(Math.random() * 100000);
const token = 'your-github-auth-token';
const users = ajax({
url: `${usersEndpoint}?since=${randomOffset()}`,
headers: {
authorization: `token ${token}`
}
}).pipe(map(res => res.response));
export { users };
I suggest you do that because there will be a lot of reloading involved soon.
Legos all the way down#
I've already mentioned it, but building software with RxJS is like building things with Lego bricks. You have all these small pieces that you assemble into larger pieces, that you then assemble into walls, that you then use to build the whole house. Some pieces don't fit together so you try a different one.
Adding the refresh button#
And looks like we just built our first Lego part. We now have a pool of Github users to choose from. Let's add the 'refresh' button.
In the original tutorial the author is using fromEvent operator to capture the button click, but we will use Subject instead. While it's possible to do it exactly like in the original tutorial by using Svelte's bind
directive, I think that RxSbjects are actually a better fit here.
Shortly explained, Rx Subjects are both observers and observables, meaning you can both push, via next
method and pull data from them by subscribing. Don't worry if this doesn't make sense right now.
Back to our problem. We will use plain Subject
as a signal to trigger our users
stream and force it to reload.
// users.ts
import { map } from 'rxjs/operators';
import { ajax } from 'rxjs/ajax';
import { Subject } from 'rxjs';
const usersEndpoint = 'https://api.github.com/users';
const randomOffset = () => Math.floor(Math.random() * 100000);
const token = 'your-github-auth-token';
// Rx Subject that we use only to trigger reloads
const reload = new Subject();
// instead of exposing subject directly we wrap it in a function
const refresh = () => reload.next();
const users = ajax({
url: `${usersEndpoint}?since=${randomOffset()}`,
headers: {
authorization: `token ${token}`
}
}).pipe(map(res => res.response));
export { users, refresh };
Let's add a button to the main file and bind out refresh
function to it.
<!-- App.svelte -->
<script>
import { users, refresh } from './users';
</script>
<button on:click={refresh}>refresh</button>
<pre>{JSON.stringify($users, null, 2)}</pre>
The button does nothing yet. We need to wire it up to re-trigger users
stream when it's clicked.
// users.ts
import { map, mergeMap } from 'rxjs/operators';
import { ajax } from 'rxjs/ajax';
import { Subject } from 'rxjs';
const usersEndpoint = 'https://api.github.com/users';
const randomOffset = () => Math.floor(Math.random() * 100000);
const token = 'your-github-auth-token';
// Rx Subject that we use only to trigger reloads
const reload = new Subject();
// instead of exposing subject directly we wrap it in a function
const refresh = () => reload.next();
const url = `${usersEndpoint}?since=${randomOffset()}`;
// let's change the users name to response instead
const response = ajax({
url: `${usersEndpoint}?since=${randomOffset()}`,
headers: {
authorization: `token ${token}`
}
}).pipe(map(res => res.response));
// when clicking refresh button we call `reload.next()`
// which triggers the observable chain.
const users = reload.pipe(mergeMap(() => response));
export { users, refresh };
You can see that we used mergeMap operator. In short, this operator will replace the outer observable with the inner observable (response
in our case).
With our changes we have introduced a couple of problems. First, when you load the page, no users are present until you click the 'refresh' button.
This is easily fixed. We can emulate the first click, and thus trigger the chain, by using the startWith operator. What we send in doesn't really matter here as we will not use it.
// users.ts
// add startWith to imports
import { map, mergeMap, startWith } from 'rxjs/operators';
// add `startWith` operator as first in the chain
const users = reload.pipe(
startWith(null),
mergeMap(() => response)
);
The second problem is that when we click the button, the data is re-fetched, but the results are the same. That is because response
stream is not re-triggered, but reused. We need to solve that. The easiest solution that comes to mind is to extract it to a function and create a new stream on every call.
Our whole users.ts
file should look like this now and it should work as expected too.
// users.ts
import { map, mergeMap, startWith } from 'rxjs/operators';
import { ajax } from 'rxjs/ajax';
import { Subject } from 'rxjs';
const usersEndpoint = 'https://api.github.com/users';
const randomOffset = () => Math.floor(Math.random() * 100000);
const token = 'your-github-auth-token';
// Rx Subject that we use only to trigger reloads
const reload = new Subject();
// instead of exposing subject directly we wrap it in a function
const refresh = () => reload.next();
// replace users with a function instead so that we return
// a new stream on every 'refresh' click
const fetchUsers = () => {
return ajax({
url: `${usersEndpoint}?since=${randomOffset()}`,
headers: {
authorization: `token ${token}`
}
}).pipe(map(res => res.response));
};
// call fetchUsers function when we click the refresh button
const users = reload.pipe(startWith(null), mergeMap(fetchUsers));
export { users, refresh };
Awesome! Second Lego part is now in place and we only had to refactor our logic to achieve this, not touching the view aka App.svelte
. Are you starting to see how we are building our app piece by piece? Nice, isn't it?
Creating a suggestions list#
We will now continue to the next step, creating a list with three user suggestions and this will require some view changes.
First, we need to think on how to get a random user from the users
stream (the list of users we got back from Github API). We can achieve that with the following code that we can add at the end.
// users.ts
// helper function to get a random user from
// the user list we got from Github
const randomUser = (users: any[]) => users[Math.floor(Math.random() * users.length)];
// random user suggestion stream
const suggestionOne = users.pipe(map(users => randomUser(users)));
export { users, refresh, suggestionOne };
So what does this code do? I will explain.
- Start with the users list
- Pipe the list to
map
operator - Select a random user from the list of all users
So far so good. We could just make three suggestions and then export/import them. But, let's hold on to that thought for now and try to implement the 'suggest' button instead. When clicked, it will give us a new user suggestion.
Reloading the individual user#
What's needed here? Well, we need to have a button to click. Obviously.
Maybe it's a good idea to replicate the functionality of the 'refresh' button with the Subject?
Maybe. Let's try.
const refreshOne = new Subject();
Now what? Something needs to happen when it's clicked, right? It should kick off (trigger) a chain of something that will get us a new user.
How about this? How about I write the code and then explain it? Deal? OK.
const refreshOne = new Subject();
const suggestionOne = refreshOne.pipe(
startWith(null),
combineLatest(users, (click, users) => randomUser(users))
);
export { users, refresh, suggestionOne, refreshOne };
Done.
What is this "combineLatest" thing, you say? I say, it's pretty awesome.
combineLatest is an RxJS operator that takes provided streams and emits them when any of the provided streams emit (or gets triggered). If you provide a function at the end, so called project function, it will pass all the streams into it. In there you can do what's needed (get a random user in our case) and pass that down the line.
The important thing with combineLatest
is that it will not emit anything until all provided streams has emitted at least once. Something to keep in mind for the future, when you are pushing buttons like mad scientist but nothing is happening.
The rest, startWith
operator, should be familiar to us by now.
Let me explain exactly what happens in our case:
- We build our suggestion observable by "deriving" data from the users observable. It means, if we don't pipe to anything else we will just get a copy of users observables.
- Next, we use the built-in
pipe
operator to build up a chain of other operators that will transform the data as it passes through that chain. - We emulate a button click (trigger the chain) with the
startWith
operator - It will go to the next step in the chain -
combineLatest
operator. combineLatest
takes thenull
click stream andusers
stream and executes our provided project function passing our current stream and dependant streams in order they are specified- In the function we take the value of the
users
stream and execute ourrandomUser
function with the list of users, the value of theusers
stream.
It's important to understand what's happening. I suggest you play around a bit, before continuing, by commenting stuff out and using good ol' console.log
to print out values.
PRO TIP: The tap
operator
When you need to debug (log) something in the stream pipeline tap operator is really handy. Just import it and plug tap(console.log)
inside the pipe
operator.
OK. Business logic is done. Let's import the suggestion in our App.svelte
file and try it out.
<!-- App.svelte -->
<script>
import { users, refresh, suggestionOne, refreshOne } from './users';
</script>
<button on:click={refresh}>refresh</button>
<!-- notice that I didn't wrap our subject in an exported function -->
<!-- I did it only for demo purposes here. You should always wrap -->
<button on:click={() => refreshOne.next()}>refresh one</button>
<pre>{JSON.stringify($suggestionOne, null, 2)}</pre>
<pre>{JSON.stringify($users, null, 2)}</pre>
Click the 'refresh one' button and see that we get a new user suggestion.
Click the 'refresh' button and notice that our suggestionOne
also reloads. It's because in RxJS every stream can be connected to each other and when triggering the top stream it can trigger other streams that depends on it. Kind of hard to understand in the beginning and very easy to get lost in, but totally awesome when you finally get it.
We are almost done. Let's add two more suggestions and add some layout to our view file.
We could copy and paste code for the rest of our suggestions, but we will create a helper function instead.
Like this.
const refreshOne = new Subject();
const refreshTwo = new Subject();
const refreshThree = new Subject();
// make sure to import `Observable` from 'rxjs'
const createSuggestion = (refresh: Observable<any>) =>
refresh.pipe(
startWith(null),
// name variable as _ to show that we don't care about it
combineLatest(users, (_, users) => randomUser(users))
);
const suggestionOne = createSuggestion(refreshOne);
const suggestionTwo = createSuggestion(refreshTwo);
const suggestionThree = createSuggestion(refreshThree);
export {
refresh, refreshOne, refreshTwo, refreshThree,
suggestionOne, suggestionTwo, suggestionThree
};
Next, let's adjust the App.svelte
a bit. Add some layout and styling.
<!-- App.svelte -->
<style>
:global(*, *:before, *:after) {
box-sizing: border-box;
}
:global(body) {
font-family: sans-serif;
background-color: #f4fffc;
}
.container {
display: flex;
min-height: 98vh;
justify-content: center;
align-items: center;
}
ul {
list-style: none;
max-width: 30em;
padding: 0;
}
.user {
display: flex;
align-items: center;
padding: 12px 18px 12px 12px;
background-color: #d0ece7;
}
li {
margin-top: 0.5em;
min-width: 30em;
}
.user a {
font-size: 1.65em;
text-decoration: none;
color: #2e2e2e;
font-weight: 600;
}
.user a:hover {
text-decoration: underline;
}
.user img {
width: 48px;
height: 48px;
margin-right: 12px;
border-radius: 9999px;
}
.user button {
margin-left: auto;
border: none;
padding: 6px 12px;
background-color: #666;
font-weight: 600;
color: #fefefe;
}
.refresh {
border: none;
border-radius: 4px;
padding: 5px 10px;
background-color: #eee;
font-size: 1.2em;
}
</style>
<script>
import {
refresh,
refreshOne,
refreshTwo,
refreshThree,
suggestionOne,
suggestionTwo,
suggestionThree
} from './users';
</script>
<div class="container">
<div>
<button on:click={refresh}>refresh</button>
<ul>
{#if $suggestionOne}
<li>
<div class="user">
<img src={$suggestionOne.avatar_url} alt={$suggestionOne.login} />
<a href={$suggestionOne.html_url}>{$suggestionOne.login}</a>
<button on:click={() => refreshOne.next()}>x</button>
</div>
</li>
{/if}
{#if $suggestionTwo}
<li>
<div class="user">
<img src={$suggestionTwo.avatar_url} alt={$suggestionTwo.login} />
<a href={$suggestionTwo.html_url}>{$suggestionTwo.login}</a>
<button on:click={() => refreshTwo.next()}>x</button>
</div>
</li>
{/if}
{#if $suggestionThree}
<li>
<div class="user">
<img src={$suggestionThree.avatar_url} alt={$suggestionThree.login} />
<a href={$suggestionThree.html_url}>{$suggestionThree.login}</a>
<button on:click={() => refreshThree.next()}>x</button>
</div>
</li>
{/if}
</ul>
</div>
</div>
We can say that we are done, but I say, we are not quite done.
Hot and cold observables#
If you open the network tab in the dev tools console and play around, you will notice that we do way too many requests than necessary. How come, you may ask?
This has to do with hot and cold RxJS observables. A topic that would require it's own article. But, briefly, and very simplified, cold (unicast) observables are started (created) each time they get a new subscriber, while hot (multicast) observables "keep" their existing state when getting new subscribers eg they don't start from the beginning again.
Thankfully, it easily fixed with the help of the share operator. Import it and add it to the end our users
stream.
// call fetchUsers function when we click the refresh button
const users = reload.pipe(startWith(null), mergeMap(fetchUsers), share());
Watch the network tab now.
You can also add a few more operators if you want to enhance the UI interaction, but we will stop here. I recommend that you go to the original article instead and look for more inspiration there if you are interested.
It's not DRY!#
With the final solution now done, there was one thing left, that was still bothering me. A think that I couldn't stop thinking about. The way suggestions are created. The DRY principle. The current solution is ugly. Code is repetitive.
André, the author, also effectively lures you in by saying:
This is not DRY, but it will keep our example simple for this tutorial, plus I think it's a good exercise to think how to avoid repetition in this case.
Of course I had to give it a try. I mean, how hard can it be?
Can we do better?#
And this is where I should have stopped. Should have gone on with my life. Done something fun instead. But the stubborn donkey in me took over. "I mean, how hard can it be," I thought.
Well. If you look at all the hundreds of comments in the original article, everyone is saying how awesome the article is, but NOT A SINGLE ONE offered a solution to the problem.
Why is that, you may ask? Because it's hard.
The hill is too steep and you are not in shape#
In RxJs land you can't just take the next step, because that step is too steep.
Before you are able to do that you have to stare at the screen for hours, trying to understand the code and the flow, and why that small change you introduced borked everything you just got working.
You have to read all the articles on the Internet and desperately google for information until your fingers hurt.
You have to curse yourself with very strong words for being stupid and cry yourself to sleep.
But suddenly, it just clicks and it's like a messiah coming down from the clear blue sky while the angel choir sings. You become almost euphoric and people might ask you what pill you just popped.
Don't worry, that high won't last long. It will actually wear off pretty quickly when you realize that there is another step you have to climb and it's just as steep as the previous one.
That's exactly the process I went through. Every. Single. Thing. What soothed the pain a bit was that I worked in bursts. You know, HDD, Hammock Driven Development.
I learned it years ago and it really works!
Onward.
Making things dynamic#
My only goal was to get rid of static suggestion list and make it dynamic, so we can easily change how many users we would like to display.
The task seemed easy - instead of returning separate suggestion, we want to return a list of user suggestions that we can iterate over. When clicking 'x' (suggest) button, we should replace that user in our list.
We can reuse many of our existing Lego parts we have already built. To start off we can delete all individual suggestions and individual refresh streams. Both from code and exports.
Next, we can rename our users
stream to userPool
, because it will be a pool of users that we want to choose three random ones from.
// call fetchUsers function when we click the refresh button
const userPool = reload.pipe(startWith(null), mergeMap(fetchUsers), share());
Now we can select a well, semi-random, first three top users from our userPool
stream. This will be our starting point.
const users = userPool.pipe(mergeAll(), take(3), toArray());
I will briefly explain what's going on here.
- We start with the
userPool
stream eg all users returned from Github API - We use the mergeAll operator to flatten the array, that is piped to us, to individual items
- We take three users only with the help of take operator
- We transform it back to one result containing a list of 3 users with the help of toArray operator
We could have done it slightly differently, for example flattening the array where we fetch the result from Github, but this will do for now.
I encourage you to try and do this as an exercise! If you succeed, then you know that you are starting to think in streams!
If you like you can try our new solution by changing App.svelte
to this.
<!-- App.svelte -->
<script>
import { refresh, users } from './users';
</script>
<div class="container">
<div>
<button class="refresh" on:click={refresh}>refresh</button>
{#if $users}
<ul>
{#each $users as user}
<li>
<div class="user">
<img src={user.avatar_url} alt={user.login} />
<a href={user.html_url}>{user.login}</a>
<button on:click={() => console.log(user.login)}>x</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
The 'refresh' button will not work, but this is something we will fix soon.
Like this. Create a new stream called suggestions
.
const suggestions = reload.pipe(
// emulate the first click to trigger the chain
startWith(null),
// replace `null` with `users` stream
mergeMap(() => users),
// log to console. useful for debugging
tap(console.log),
// start with an empty array to keep Svelte store happy
startWith([])
);
The flow explained:
- We derive our
suggestions
stream fromreload
button stream - We emulate the first click to trigger the chain
- We replace the outer observable with inner observable (users stream)
- We console log the result of the
mergeMap
- We start with an empty array to keep Svelte's each loop happy
I think that suggestions
is actually a better word in this context than users
.
Import suggestions
in our App.svelte
and replace all references to $users
with $suggestions
.
Everything should work just as it did before except we can now refresh our suggestions list. Yay! Winning!
Adding slowness
In our App.svelte
we have a null
check were we don't show the list if it's null
. The thing is that reloads are happening so fast that we don't even notice. To make it visible we can replace mergeMap
operator line with this one. I will leave it up to you to figure out what it does.
mergeMap(() => users.pipe(delay(1000), startWith(null)))
This is the thing with RxJS. By entering right things at the right places you can get lots of things done quickly. I didn't say it was easy though.
Reloading user suggestions#
Alright, we finally have come to the part that was the most painful for me. Generating new suggestions for the individual user.
Because no matter how I tried I kept coming to the same fact, that I needed to keep state somewhere somehow, but I really didn't want to use external state variables. Hey, that would be cheating!
After lots of experimenting and googling I finally got it to work. Turns out we don't need any state at all.
First, let's think what we need to do. We already have a suggestions stream so we need to:
- Create and wire up individual suggestion reload button
- When button is clicked we we need to know what user that was clicked for
- Somehow take that user and replace her in our suggestion list
Doesn't sound to hard, right?
Implementing individual suggestion reload#
For triggering individual reloads we will again use RxJS subjects, but this time we will use BehaivorSubject instead of the plain one.
BehaviorSubject is special. It keeps track of the latest state and upon every new subscriber it returns the latest state to them when they subscribe. We can also initialize it with as starting state and that's exactly what we need.
Why do we need that? Glad you asked! It's because we will need to use combineLatest
operator again and this operator will not emit anything until all of the streams it depends on emit. Remember?
If we decide to use the plain Subject
we end up in a limbo state. We would need to click the 'x' button it so it emits, but we cannot access it because it's not visible since the combineLatest
operator is waiting for our subject to first emit something.
We will start simple, and refactor the code as we go.
// users.ts
// the suggest 'x' button stream
// we initialize it with an empty string as starting state
const suggest = new BehaviorSubject('');
// wrap the suggest stream in the helper function that we export
const replace = (username: string) => suggest.next(username);
// our main suggestions stream
const suggestions = reload.pipe(
// emulate the first click of reload button to trigger the chain
startWith(null),
// replace `null` with `users` stream
mergeMap(() => users),
// log to console. useful for debugging
tap(console.log),
// this is where we handle new user suggestions
combineLatest(userPool, suggest, (users, pool, suggest) => {
console.log(users, pool, suggest);
return users;
}),
// start with an empty array to keep Svelte store happy
startWith([])
);
export { users, replace, refresh, suggestions };
Import replace
in the App.svelte
and wire it up as an action in the 'x' button.
<script>
import { refresh, suggestions, replace } from './users';
</script>
<div class="container">
<div>
<button class="refresh" on:click={refresh}>refresh</button>
{#if $suggestions}
<ul>
{#each $suggestions as user}
<li>
<div class="user">
<img src={user.avatar_url} alt={user.login} />
<a href={user.html_url}>{user.login}</a>
<button on:click={() => replace(user.login)}>x</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
Implementing the core suggestion logic#
If you click the 'x' button now and open dev tools you will see the console.log
output in our "project" function we supplied last in our combineLatest
statement. This is where we will implement the actual suggestion replacement.
I recommend you to click the button a few times, inspect its output and think on how we can replace the individual user in our user list before continuing.
Did you do it? No? OK then, I will show you how I solved it.
First, let's extract our "project" function so we can concentrate on it.
// our project function which contains the suggestion logic
const replaceUser = (users: any[], pool: any[], login: string) => {
console.log(users, pool, login);
return users;
};
// replace `combineLatest` in `suggestions` stream to this
// combineLatest(userPool, suggest, replaceUser)
Cool. In that function we get the users (our suggestions) list, a pool of all users and the login (username) of the user that needs to be replaced in the list.
Here is how we can solve it.
- Find the username to be replaced in the users array
- If user is not found, return the users array
- If user is found, pick a new random user from the user pool
- If new random user already exists in the users array, pick new one
- Replace old user in users array with new one
- Return mutated users list
And here is the actual code. I hope I got it right!
const replaceUser = (users: any[], pool: any[], login: string) => {
const getIndex = (username: string) =>
users.findIndex(user => user.login === username);
const idx = getIndex(login);
while (true || idx !== -1) {
let newUser = randomUser(pool);
if (getIndex(newUser.login) === -1) {
users.splice(idx, 1, newUser);
break;
}
}
return users;
};
Add this code, save your files and you should see everything work.
Try changing the number of displayed suggestions to five and see that it still works.
Our final goal is now complete. We have a truly dynamic suggestions list that it also totally DRY!
But how does it work?#
Remember that almost everything in Javascript is passed by reference and therefore we are actually mutating the array returned from the users
stream.
You can validate this if you want by importing users
stream in App.svelte
and doing cheap man's debugging aka pre + JSON.stringify
.
We can say that our suggestions stream sits and listens at two points in code:
- When reload button is clicked it kicks off the whole stream chain again
- When some dependant stream in
combineLatest
operator changes only it gets executed
Takeaways#
Did you notice how we used Svelte just as a thin view layer? Our script statement in App.svelte
contains only imports. All of the logic is encapsulated in separate files.
One nice side effect of doing it this way is that it makes our code very TDD-friendly.
With that said, here are the main takeaways:
Mind bending stuff. Learning RxJS is super hard, but also super rewarding. Declarative programming makes your life easier, but it also requires more of you.
Less writing, more thinking. I don't like writing imperative code, because I often already know what I need to write, and it's so damn boring to type. Writing FRP code actually involves less writing and more thinking. Thinking about how to solve the problem with streams.
RxJS is not for beginners. You need some functional programming experience and a solid knowledge of Javascript. Add Typescript to the mix and you will start crying. Personally, I think that RxJS is even harder to learn than Vim!
One-to-many. Every problem can be solved in many different ways in RxJS. There is no right or wrong. Only more and less efficient, feels like.
RxJS not a hammer. RxJS is super cool, but don't use it for everything just because you can. Most of the small problems can be solved in much more simple (boring) way instead.
Svelte + RxJS = WINNING. In my opinion, Svelte is a perfect fit for RxJS. Both are truly reactive and Svelte has almost native RxJS support with its auto subscriptions. It's a beautiful marriage.
Better developer. Even if you are not planning on using RxJS it's still a very good exercise to learn it. It will level up your game. Guaranteed.
Conclusion#
This was a very long article. Thanks for sticking all the way to the end with me!
As always, I hope that you learned something, even if you head might hurt slightly right now.
Here is the link to code https://github.com/codechips/svelte-rxjs-intro-frp
If you know of a different way of solving this please make a PR so I can learn. It would make me really really happy!