Sådan opbygges GitHub-søgefunktionalitet i React med RxJS 6 og Recompose
Dette indlæg er beregnet til dem med React og RxJS erfaring. Jeg deler bare mønstre, som jeg fandt nyttige, mens jeg lavede dette brugergrænseflade.
Her er hvad vi bygger:
Ingen klasser, livscyklus kroge eller setState
.
Opsætning
Alt er på min GitHub.
git clone //github.com/yazeedb/recompose-github-ui cd recompose-github-ui yarn install
Den master
gren har det færdige projekt, så kassen start
gren, hvis du ønsker at følge med.
git checkout start
Og kør projektet.
npm start
Appen skal køre localhost:3000
, og her er vores første brugergrænseflade.
Åbn projektet i din yndlings teksteditor og visning src/index.js
.
Komponer igen
Hvis du ikke har set det endnu, er Recompose et vidunderligt React-værktøjsbælte til fremstilling af komponenter i en funktionel programmeringsstil. Det har masser af funktioner, og jeg ville have svært ved at vælge mine favoritter.
Det er Lodash / Ramda, men for React. Jeg elsker også, at de støtter observerbare. Citat fra dokumenterne:
Det viser sig, at meget af React Component API kan udtrykkes i form af observerbare
Vi udøver dette koncept i dag! ?
Streaming af vores komponent
Lige nu App
er en almindelig React-komponent. Vi kan returnere det gennem en observerbar ved hjælp af Recompose's componentFromStream-funktion.
Denne funktion gengiver oprindeligt en nul-komponent og gengives igen, når vores observerbare returnerer en ny værdi.
Et strejf af konfiguration
Komponér streams igen efter ECMAScript Observable Proposal. Den beskriver, hvordan observerbare ting skal fungere, når de til sidst sendes til moderne browsere.
Indtil de er fuldt implementeret, stoler vi på biblioteker som RxJS, xstream, most, Flyd osv.
Recompose ved ikke, hvilket bibliotek vi bruger, så det giver en setObservableConfig
til at konvertere ES Observables til / fra hvad vi har brug for.
Opret en ny fil, der src
kaldes observableConfig.js
.
Og tilføj denne kode for at gøre Recompose kompatibel med RxJS 6:
import { from } from 'rxjs'; import { setObservableConfig } from 'recompose'; setObservableConfig({ fromESObservable: from });
Importer det til index.js
:
import './observableConfig';
Og vi er klar!
Komponer + RxJS
Importér componentFromStream
.
import React from 'react'; import ReactDOM from 'react-dom'; import { componentFromStream } from 'recompose'; import './styles.css'; import './observableConfig';
Og begynd at omdefinere App
med denne kode:
const App = componentFromStream((prop$) => { // ... });
Bemærk, der componentFromStream
tager en tilbagekaldsfunktion, der forventer en prop$
stream. Ideen er, at vores props
bliver en observerbar, og vi kortlægger dem til en React-komponent.
Og hvis du har brugt RxJS, kender du den perfekte operatør til at kortlægge værdier.
Kort
Som navnet antyder, transformerer du Observable(something)
til Observable(somethingElse)
. I vores tilfælde Observable(props)
ind i Observable(component)
.
Importer map
operatøren:
import { map } from 'rxjs/operators';
Og omdefiner app:
const App = componentFromStream((prop$) => { return prop$.pipe( map(() => ( )) ); });
Lige siden RxJS 5 bruger vi i pipe
stedet for at kæde operatører.
Gem og kontroller din brugergrænseflade, samme resultat!
Tilføjelse af en begivenhedshåndterer
Nu gør vi vores input
lidt mere reaktive.
Importer createEventHandler
fra Recompose.
import { componentFromStream, createEventHandler } from 'recompose';
Og brug det sådan:
const App = componentFromStream((prop$) => { const { handler, stream } = createEventHandler(); return prop$.pipe( map(() => ( {' '} )) ); });
createEventHandler
er et objekt med to interessante egenskaber: handler
og stream
.
Under emhætten handler
er en begivenhedsudsender, der skubber værdier til stream
, hvilket er en observerbar udsendelse af disse værdier til sine abonnenter.
Så vi kombinerer det stream
observerbare og det prop$
observerbare for at få adgang til den input
aktuelle værdi.
combineLatest
er et godt valg her.
Kylling og æg problem
To use combineLatest
, though, both stream
and prop$
must emit. stream
won’t emit until prop$
emits, and vice versa.
We can fix that by giving stream
an initial value.
Import RxJS’s startWith
operator:
import { map, startWith } from 'rxjs/operators';
And create a new variable to capture the modified stream
.
const { handler, stream } = createEventHandler(); const value$ = stream.pipe( map((e) => e.target.value), startWith('') );
We know that stream
will emit events from input
's onChange, so let’s immediately map each event
to its text value.
On top of that, we’ll initialize value$
as an empty string — an appropriate default for an empty input
.
Combining It All
We’re ready to combine these two streams and import combineLatest
as a creation method, not as an operator.
import { combineLatest } from 'rxjs';
You can also import the tap
operator to inspect values as they come:
import { map, startWith, tap } from 'rxjs/operators';
And use it like so:
const App = componentFromStream((prop$) => { const { handler, stream } = createEventHandler(); const value$ = stream.pipe( map((e) => e.target.value), startWith('') ); return combineLatest(prop$, value$).pipe( tap(console.warn), map(() => ( )) ); });
Now as you type, [props, value]
is logged.
User Component
This component will be responsible for fetching/displaying the username we give it. It’ll receive the value
from App
and map it to an AJAX call.
JSX/CSS
It’s all based off this awesome GitHub Cards project. Most of the stuff, especially the styles, is copy/pasted or reworked to fit with React and props.
Create a folder src/User
, and put this code into User.css
:
And this code into src/User/Component.js
:
The component just fills out a template with GitHub API’s standard JSON response.
The Container
Now that the “dumb” component’s out of the way, let’s do the “smart” component:
Here’s src/User/index.js
:
import React from 'react'; import { componentFromStream } from 'recompose'; import { debounceTime, filter, map, pluck } from 'rxjs/operators'; import Component from './Component'; import './User.css'; const User = componentFromStream((prop$) => { const getUser$ = prop$.pipe( debounceTime(1000), pluck('user'), filter((user) => user && user.length), map((user) =>{user}
) ); return getUser$; }); export default User;
We define User
as a componentFromStream
, which returns a prop$
stream that maps to an
.
debounceTime
Since User
will receive its props through the keyboard, we don’t want to listen to every single emission.
When the user begins typing, debounceTime(1000)
skips all emissions for 1 second. This pattern’s commonly employed in type-aheads.
pluck
This component expects prop.user
at some point. pluck
grabs user
, so we don’t need to destructure our props
every time.
filter
Ensures that user
exists and isn’t an empty string.
map
For now, just put user
inside an
tag.
Hooking It Up
Back in src/index.js
, import the User
component:
import User from './User';
And provide value
as the user
prop:
return combineLatest(prop$, value$).pipe( tap(console.warn), map(([props, value]) => ( {' '} )) );
Now your value’s rendered to the screen after 1 second.
Good start, but we need to actually fetch the user.
Fetching the User
GitHub’s User API is available here. We can easily extract that into a helper function inside User/index.js
:
const formatUrl = (user) => `//api.github.com/users/${user}`;
Now we can add map(formatUrl)
after filter
:
You’ll notice the API endpoint is rendered to the screen after 1 second now:
But we need to make an API request! Here comes switchMap
and ajax
.
switchMap
Also used in type-aheads, switchMap
’s great for literally switching from one observable to another.
Let’s say the user enters a username, and we fetch it inside switchMap
.
What happens if the user enters something new before the result comes back? Do we care about the previous API response?
Nope.
switchMap
will cancel that previous fetch and focus on the current one.
ajax
RxJS provides its own implementation of ajax
that works great with switchMap
!
Using Them
Let’s import both. My code is looking like this:
import { ajax } from 'rxjs/ajax'; import { debounceTime, filter, map, pluck, switchMap } from 'rxjs/operators';
And use them like so:
const User = componentFromStream((prop$) => { const getUser$ = prop$.pipe( debounceTime(1000), pluck('user'), filter((user) => user && user.length), map(formatUrl), switchMap((url) => ajax(url).pipe( pluck('response'), map(Component) ) ) ); return getUser$; });
Switch from our input
stream to an ajax
request stream. Once the request completes, grab its response
and map
to our User
component.
We’ve got a result!
Error handling
Try entering a username that doesn’t exist.
Even if you change it, our app’s broken. You must refresh to fetch more users.
That’s a bad user experience, right?
catchError
With the catchError
operator, we can render a reasonable response to the screen instead of silently breaking.
Import it:
import { catchError, debounceTime, filter, map, pluck, switchMap } from 'rxjs/operators';
And stick it to the end of your ajax
chain.
switchMap((url) => ajax(url).pipe( pluck('response'), map(Component), catchError(({ response }) => alert(response.message)) ) );
At least we get some feedback, but we can do better.
An Error Component
Create a new component, src/Error/index.js
.
import React from 'react'; const Error = ({ response, status }) => ( Oops!
{status}: {response.message} Please try searching again.
); export default Error;
This will nicely display response
and status
from our AJAX call.
Let’s import it in User/index.js
:
import Error from '../Error';
And of
from RxJS:
import { of } from 'rxjs';
Remember, our componentFromStream
callback must return an observable. We can achieve that with of
.
Here’s the new code:
ajax(url).pipe( pluck('response'), map(Component), catchError((error) => of()) );
Simply spread the error
object as props on our component.
Now if we check our UI:
Much better!
A Loading Indicator
Normally, we’d now require some form of state management. How else does one build a loading indicator?
But before reaching for setState
, let’s see if RxJS can help us out.
The Recompose docs got me thinking in this direction:
Instead of setState()
, combine multiple streams together.
Edit: I initially used BehaviorSubject
s, but Matti Lankinen responded with a brilliant way to simplify this code. Thank you Matti!
Import the merge
operator.
import { merge, of } from 'rxjs';
Når anmodningen er fremsendt, fusionerer vi vores ajax
med en indlæsningskomponent-stream.
Indvendigt componentFromStream
:
const User = componentFromStream((prop$) => { const loading$ = of(Loading...
); // ... });
En simpel h3
lastindikator blev til en observerbar! Og brug det sådan:
const loading$ = of(Loading...
); const getUser$ = prop$.pipe( debounceTime(1000), pluck('user'), filter((user) => user && user.length), map(formatUrl), switchMap((url) => merge( loading$, ajax(url).pipe( pluck('response'), map(Component), catchError((error) => of()) ) ) ) );
Jeg elsker, hvor kortfattet dette er. Ved indrejse switchMap
flettes loading$
og ajax
observerbare.
Da det loading$
er en statisk værdi, udsender den først. Når asynkrone ajax
finish, men det vil udsende og blive vist på skærmen.
Før vi tester det, kan vi importere delay
operatøren, så overgangen ikke sker for hurtigt.
import { catchError, debounceTime, delay, filter, map, pluck, switchMap, tap } from 'rxjs/operators';
Og brug det lige før map(Component)
:
ajax(url).pipe( pluck('response'), delay(1500), map(Component), catchError((error) => of()) );
Vores resultat?
Jeg undrer mig over, hvor langt jeg skal tage dette mønster, og i hvilken retning. Del dine tanker!