This tutorial assumes you are already familiar with both RxJS
and React.
For this tutorial we will be borrowing the Github issues example that is taught
in the Advanced Tutorial
of the Redux Toolkit.
It's a great example because it starts with a plain React application and it then
shows how to migrate that application to Redux using the Redux Toolkit (RTK). One of the many good
things about the tutorial, is that it illustrates the mental models required to manage
state efficiently with RTK. In this tutorial we will try to follow the same approach.
The example application for this tutorial is a Github Issues viewer app. It allows
the user to enter the names of a Github org and repository, fetch the current list
of open issues, page through the issues list, and view the contents and comments
of a specific issue.
The starting commit for this application is a plain React implementation that uses
function components with hooks for state and side effects like data fetching. The
code is already written in TypeScript, and the styling is done via CSS Modules.
Let's start by viewing the original plain React app in action:
It's worth noting that there are a couple of tiny bugs (or annoyances) with this
React implementation:
Changing the "Issues Page" number and jumping to that page will highlight the
pagination number on the footer. However, changing the pagination number through
the footer does not update the pagination number at the top.
When the user loads a different repo, the issues page doesn't go back to the first page,
which is problematic: If the user was looking at page 5 of the initial repo
and then tries to go to a different repo which doesn't have as many pages,
the results don't load properly. We think that it would be desirable to go back
to the first page whenever the user loads a different repo.
We will be addressing these issues as we migrate the initial code to React-RxJS.
For this tutorial we will need the following dependencies:
rxjs: since these are bindings for RxJS ๐
@react-rxjs/core: the core package of React-RxJS
react-error-boundary: React-RxJS integrates very nicely with React Error
Boundaries. react-error-boundary is a tiny library that provides a nice
abstraction to build them, by declaring a fallback component and recovery strategy,
in a similar way to Suspense Boundaries.
Also, we are not going to need Axios, because we will be using rxjs/ajax instead.
The original API uses Axios, which is a great tool for handling requests. We could
keep the API as it is, because RxJS can easily treat Promises as Observables.
However, since we are going to be using RxJS, it makes sense to use RxJS instead
of Axios. It's a pretty straightforward change:
-import axios from 'axios'
+import { ajax } from 'rxjs/ajax'
import parseLink, { Links } from 'parse-link-header'
Now that we have everything ready, let's think for a moment about the state of
this app. Luckily for us, there is not a lot of it. So, let's represent the different
state entities and their relations on a diagram:
At the very top we have the different events that can happen. The 3 different user interactions:
Changing the repo
Changing the page
Selecting / unselecting an issue
These are the events that will propagate changes to our state entities.
Then we have the following state entities:
"Current repo & page": Since the page and the repo are very tightly coupled, it makes
sense to have an entity that represents the current state of the both of them. This entity
depends on 2 different user interactions: changing the repo and changing the page.
From this entity we can easily derive the "list of issues" and the "current page".
We also have the "# of open issues" which depends on the "changing repo" event.
Then there is the "issue details" which will change whenever the user selects/unselects an issue.
And finally we have the "issue comments" which depend on the "issue details".
That's it. That's all the app-level state. It's worth pointing out that the UI
doesn't allow the user to change the repo or the page while there is a selected issue.
Now that we have identified the state, let's represent it using RxJS streams, and
let's create the necessary React hooks.
One nice thing about Reactive Programming is that it's possible to declare state
in a way that reads from top to bottom. That's because each state entity is only
coupled to the entities that it depends on.
In order to illustrate that, we will be putting all the state of this app in the
same file. Normally, it would be better to break this file down into smaller
pieces and to co-locate each piece closer to where it is being used.
Let's first define and export the default states of the app:
exportconstINITIAL_ORG="rails"
exportconstINITIAL_REPO="rails"
Next, let's create the entry points for the user interactions:
Now that we already have the top-level streams, let's create a stream that represents
the "current repo and page" entity. We want to reset the page to 1 when the selected repo changes,
so we can represent this behavior with merge:
exportconst[useCurrentRepo, currentRepo$]=bind(
repoSubject$.pipe(
startWith({
org:INITIAL_ORG,
repo:INITIAL_REPO,
}),
),
)
const currentRepoAndPage$ =merge(
// When repo changes, update repo and reset page to 1
currentRepo$.pipe(
map((currentRepo)=>({
...currentRepo,
page:1,
})),
),
// When page changes
pageSelected$.pipe(
filter((page)=> page >0),
// keep same repo, update page
withLatestFrom(currentRepo$),
map(([page, repo])=>({...repo, page })),
),
).pipe(shareLatest())
From this stream we can extract the current page:
exportconst[useCurrentPage]=bind(
currentRepoAndPage$.pipe(map(({ page })=> page)),
)
And following our model, the list of issues also depends on this stream, but the
list of issues needs to be loaded from the API.
In this example we also want to use React Suspense: When the user changes the repo
or page, we want to show a suspended state while the new issue list is loading
(i.e. "Loading issues..."). The way we can do this, is by emitting the
SUSPENSE symbol, that means that there's data being loaded in this stream.
This can be expressed reactively as:
This way, every time the current repo or page changes, the useIssues hook
will send another query to the API to keep everything up to date, suspending the
component(s) that depend on it while it's fetching the new values.
We can use the same pattern to retrieve the number of open issues:
Now, since issues$ and openIssuesLen$ are observables that trigger side-effects,
it's important that their initial subscriptions happen before react renders the
components that depend on them. That's why we are going to define a top-level
subscription that ensures that.
We need to also make sure that an error won't close this top-level subscription:
And lastly we need to declare the state when an issue is selected: Following
similar logic, we need to load the issue details when an issue is selected,
as well as its comments:
As this pattern of switchMap and startWith(SUSPENSE) is something that's
often used, react-rxjs exports switchMapSuspended
in @react-rxjs/utils that makes it sightly less verbose.
Here we also need to create a subscription to issueComments$ in order to ensure
that the first subscription happens before react renders the components that
depend on it. Notice that by just subscribing to issueComment$, all the streams
that depend on it will also get a subscription:
With Suspense, we don't need to manage the loading states by ourselves - React
will. This, coupled with the fact that the state can be lifted out, allows us to simplify
the original app to a couple of simple components.
For the "search repository" form, by having the state in a separate file, we can
just import those bits that we need directly, and this way the parent doesn't
need to get coupled to values that it doesn't need:
-import React, { useState, ChangeEvent } from 'react'
+import React, { useState, ChangeEvent, useEffect } from 'react'
The page for the list of issues is also greatly simplified,
because all the state management on this part is already done, and it turns out
that this component is not the consumer of any of the state it managed - The
consumers are their children, which will access whatever they need themselves.
This component still has a responsibility though: to catch any error that would
happen on fetch, and show a fallback UI. React-RxJS lets us use ErrorBoundaries,
not only for the regular errors that happen within React's Components, but also
for those errors that are generated in a stream.
What will happen is that, if a component uses a stream that emits an error, it
will propagate that error to the nearest error boundary. If that happens, the
Error Boundary will show the fallback UI, and we can decide how to recover. In
our case, we want to show the components when the user selects another
repository or another page, so we can set this up easily by using a useEffect:
For the header, we can get rid of its props (as we have already declared its state),
and we will also take the chance to represent the loading state by
using React's Suspense:
-import React from 'react'
+import React, { Suspense } from 'react'
+import { useCurrentRepoOpenIssuesCount, useCurrentRepo } from 'state'
When react renders IssuesListLoaded, it will call useIssues(),
which internally will subscribe to the stream, and start fetching the value
from GitHub's API. As that value won't be resolved immediately, the component
will be put in suspense until we get a response back from the server.
At that point, the component will exit suspense and issues will have
the value expected.
Then, when the user changes to another repo or page, useIssues() will put
the component in suspense again until the new request has loaded.
Here, by also using error boundaries and suspense, we can break down this
component into smaller ones. There are too many changes
to be able to follow this, but the result would be:
And lastly for the comments of the selected issue, we can also just grab the
hook from where we declared the state and use it. Because of Suspense, we again
don't need to handle the loading case.
import ReactMarkdown from 'react-markdown'
import { insertMentionLinks } from 'utils/stringUtils'
-import { Issue, Comment } from 'api/githubAPI'
+import { Comment } from 'api/githubAPI'
import { UserWithAvatar } from 'components/UserWithAvatar'
import styles from './IssueComments.module.css'
-
-interface ICLProps {
- issue: Issue
- comments: Comment[]
-}
+import { useIssueComments } from 'state'
interface ICProps {
comment: Comment
@@ -35,20 +31,8 @@ function IssueComment({ comment }: ICProps) {
)
}
-export function IssueComments({ comments = [], issue }: ICLProps) {
As another advantage, it's worth noting that in this example we've decided to
keep all the state defined in a single file: The example is small
enough, and it's easier to explain this way. However, in a real application you can split
and co-locate the state in each of the relevant parts of your application, and it
will play nicely with code-splitting, if you were to use lazy imports. Let's
quickly try this to see how it plays a big role in load time.
For this example, one part of the application that can be split from the main
app is the page for issue details: It won't be needed until the user
clicks on one of the issues, so it's a perfect starting point.
With React-RxJS we can just move the streams that are only used by that page
into a separate file. Let's put it next to where it will be used, the
IssuesDetailsPage and IssuesComments components:
Now let's compare the speed between the original version without code splitting
and the one that we have optimised with React-RxJS. Looking at the
Chrome Network tab with 3G for react-state:
We see that the chunk that prevents the application from starting until it's
completely loaded weighs 76.8kB compressed (240kB uncompressed), and it took
3.71s to load. And if we take a look at the same tab for React-RxJS with code
splitting:
We can see that it weighs less, and so it also takes less time to load: About
20kB (40kB uncompressed) less and 12% faster. Although we didn't move 40kB of
minified code into a separate chunk, we reached this value because webpack
performs tree-shaking. This means that things like the 2 API calls
that are only done from the IssuesDetailsPage component, or the RxJS operators
that are only being used for the IssuesDetailsPage, were also excluded from
the main chunk.