So erstellen Sie GitHub-Suchfunktionen in React with RxJS 6 und Recompose

Dieser Beitrag richtet sich an Personen mit React- und RxJS-Erfahrung. Ich teile nur Muster, die ich beim Erstellen dieser Benutzeroberfläche als nützlich empfunden habe.

Folgendes bauen wir:

Keine Klassen, Lebenszyklus-Hooks oder setState.

Installieren

Alles ist auf meinem GitHub.

git clone //github.com/yazeedb/recompose-github-ui cd recompose-github-ui yarn install 

Die masterFiliale hat das fertige Projekt. Überprüfen Sie die startFiliale, wenn Sie mitmachen möchten.

git checkout start

Und führen Sie das Projekt aus.

npm start

Die App sollte laufen localhost:3000und hier ist unsere erste Benutzeroberfläche.

Öffnen Sie das Projekt in Ihrem bevorzugten Texteditor und zeigen Sie es an src/index.js.

Neu komponieren

Wenn Sie es noch nicht gesehen haben, ist Recompose ein wunderbarer React Utility-Gürtel zum Erstellen von Komponenten in einem funktionalen Programmierstil. Es hat eine Menge Funktionen und es fällt mir schwer, meine Favoriten auszuwählen.

Es ist Lodash / Ramda, aber für React. Ich finde es auch toll, dass sie Observable unterstützen. Zitat aus den Dokumenten:

Es stellt sich heraus, dass ein Großteil der React Component API in Form von Observablen ausgedrückt werden kann

Wir werden dieses Konzept heute anwenden! ?

Streaming unserer Komponente

Im Moment Appist eine gewöhnliche React-Komponente. Wir können es über ein Observable mit der ComponentFromStream-Funktion von Recompose zurückgeben.

Diese Funktion rendert zunächst eine Nullkomponente und rendert erneut, wenn unser Observable einen neuen Wert zurückgibt.

Ein Schuss Config

Das erneute Zusammenstellen von Streams folgt dem ECMAScript Observable Proposal. Es wird festgelegt, wie Observables funktionieren sollen, wenn sie schließlich an moderne Browser gesendet werden.

Bis sie vollständig implementiert sind, verlassen wir uns jedoch auf Bibliotheken wie RxJS, xstream, most, Flyd und so weiter.

Recompose weiß nicht, welche Bibliothek wir verwenden, daher bietet es eine Möglichkeit setObservableConfig, ES Observables in / von allem zu konvertieren, was wir benötigen.

Erstellen Sie eine neue Datei in srcaufgerufen observableConfig.js.

Fügen Sie diesen Code hinzu, um Recompose mit RxJS 6 kompatibel zu machen:

import { from } from 'rxjs'; import { setObservableConfig } from 'recompose'; setObservableConfig({ fromESObservable: from }); 

Importieren Sie es in index.js:

import './observableConfig'; 

Und wir sind bereit!

+ RxJS neu zusammensetzen

Importieren componentFromStream.

import React from 'react'; import ReactDOM from 'react-dom'; import { componentFromStream } from 'recompose'; import './styles.css'; import './observableConfig'; 

Und beginnen Sie Appmit diesem Code neu zu definieren:

const App = componentFromStream((prop$) => { // ... }); 

Beachten Sie, dass componentFromStreameine Rückruffunktion einen prop$Stream erwartet . Die Idee ist, dass wir propsbeobachtbar werden und sie einer React-Komponente zuordnen.

Und wenn Sie RxJS verwendet haben, kennen Sie den perfekten Operator zum Zuordnen von Werten.

Karte

Wie der Name schon sagt, verwandeln Sie sich Observable(something)in Observable(somethingElse). In unserem Fall Observable(props)in Observable(component).

Importieren Sie den mapOperator:

import { map } from 'rxjs/operators'; 

Und App neu definieren:

const App = componentFromStream((prop$) => { return prop$.pipe( map(() => ( )) ); }); 

Seit RxJS 5 verwenden wir pipeOperatoren anstelle von Verkettungen.

Speichern und überprüfen Sie Ihre Benutzeroberfläche, das gleiche Ergebnis!

Hinzufügen eines Ereignishandlers

Jetzt werden wir unsere inputetwas reaktiver machen.

Importieren Sie das createEventHandlervon Recompose.

import { componentFromStream, createEventHandler } from 'recompose'; 

Und benutze es so:

const App = componentFromStream((prop$) => { const { handler, stream } = createEventHandler(); return prop$.pipe( map(() => ( {' '} )) ); }); 

createEventHandlerist ein Objekt mit zwei interessanten Eigenschaften: handlerund stream.

Unter der Haube handlerbefindet sich ein Ereignisemitter, der Werte anschiebt. Dies streamist eine beobachtbare Sendung, die diese Werte an seine Abonnenten sendet.

Also kombinieren wir das streamObservable und das prop$Observable, um auf den inputaktuellen Wert des zuzugreifen .

combineLatest ist hier eine gute Wahl.

Henne-Ei-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 BehaviorSubjects, but Matti Lankinen responded with a brilliant way to simplify this code. Thank you Matti!

Import the merge operator.

import { merge, of } from 'rxjs'; 

When the request is made, we’ll merge our ajax with a Loading Component stream.

Inside componentFromStream:

const User = componentFromStream((prop$) => { const loading$ = of(

Loading...

); // ... });

A simple h3 loading indicator turned into an observable! And use it like so:

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()) ) ) ) );

I love how concise this is. Upon entering switchMap, merge the loading$ and ajax observables.

Since loading$ is a static value, it’ll emit first. Once the asynchronous ajax finishes, however, it’ll emit and be displayed on the screen.

Before testing it out, we can import the delay operator so the transition doesn’t happen too fast.

import { catchError, debounceTime, delay, filter, map, pluck, switchMap, tap } from 'rxjs/operators'; 

And use it just before map(Component):

ajax(url).pipe( pluck('response'), delay(1500), map(Component), catchError((error) => of()) ); 

Our result?

I’m wondering how far to take this pattern and in what direction. Please share your thoughts!