This article is intended for people with experience with React and RxJS. I am only sharing templates that I find useful to create such a UI.
Here is what we do:
No classes, work with life cycle or setState
.
All you need is in my GitHub repository.
git clone https://github.com/yazeedb/recompose-github-ui cd recompose-github-ui yarn install
In the master
branch is the finished project. Switch to the start
branch if you want to progress in steps.
git checkout start
And run the project.
npm start
The application should start at localhost:3000
and here is our initial UI.
Launch your favorite editor and open the src/index.js
.
If you are not familiar with Recompose yet, this is a wonderful library that allows you to create React components in a functional style. It contains a large set of functions. Here are my favorite ones.
It's like Lodash / Ramda, only for React.
I am also very glad that it supports the Observer pattern. Citing documentation :
It turns out that most of the React Component API can be expressed in terms of the Observer pattern.
Today we will practice with this concept!
So far, our App
is the most common React component. Using the componentFromStream
function from the Recompose library, we can get it through an observable object.
The componentFromStream
function starts rendering with each new value from our observable. If there are no values yet, it null
.
Recompose streams follow the ECMAScript Observable Proposal document. It describes how Observable objects should work when they are implemented in modern browsers.
For now we will use libraries such as RxJS, xstream, most, Flyd, etc.
Recompose does not know which library we use, so it provides the setObservableConfig
function. With it, you can convert all that we need to ES Observable.
Create a new file in the src
folder and name it observableConfig.js
.
To connect RxJS 6 to Recompose, write the following code in it:
import { from } from 'rxjs'; import { setObservableConfig } from 'recompose'; setObservableConfig({ fromESObservable: from });
Import this file into index.js
:
import './observableConfig';
C this all!
Add the import componentFromStream
to index.js
:
import { componentFromStream } from 'recompose';
Let's start overriding the App
component:
const App = componentFromStream(prop$ => { ... });
Note that componentFromStream
takes as its argument a function with the parameter prop$
, which is an observable version of props
. The idea is that using map to turn ordinary props
into React components.
If you used RxJS, you should be familiar with the map operator.
As the name implies, map turns Observable(something)
into Observable(somethingElse)
. In our case, Observable(props)
in Observable(component)
.
Import the map
statement:
import { map } from 'rxjs/operators';
Let's add our component App
:
const App = componentFromStream(prop$ => { return prop$.pipe( map(() => ( <div> <input placeholder="GitHub username" /> </div> )) ) });
With RxJS 5, we use pipe
instead of a chain of statements.
Save the file and check the result. Nothing changed!
Now we will make our input field a little reactive.
Add an import createEventHandler
:
import { componentFromStream, createEventHandler } from 'recompose';
We will use this:
const App = componentFromStream(prop$ => { const { handler, stream } = createEventHandler(); return prop$.pipe( map(() => ( <div> <input onChange={handler} placeholder="GitHub username" /> </div> )) ) });
The object created by createEventHandler
has two interesting fields: handler
and stream
.
Under the hood, handler
- event source (event emiter), which transmits values to stream
. And stream
in turn, is an observable object that passes values to subscribers.
We link stream
and prop$
to each other to get the current value of the input field.
In our case, using a combineLatest
function is a good choice.
To use combineLatest
, both stream
and prop$
must release values. But stream
will not release anything until some value releases prop$
and vice versa.
You can fix this by setting the stream
initial value.
Port the startWith statement from RxJS:
import { map, startWith } from 'rxjs/operators';
Create a new variable to get the value from the updated stream
:
// App component const { handler, stream } = createEventHandler(); const value$ = stream.pipe( map(e => e.target.value) startWith('') );
We know that stream
will issue events when the input field changes, so let's translate them into text right away.
And since the default value for the input field is an empty string, we initialize the value$
object with the value$
''
.
Now we are ready to link both threads. Import combineLatest
as a method for creating Observable objects, not as an operator .
import { combineLatest } from 'rxjs';
You can also import the tap
operator to examine incoming values.
import { map, startWith, tap } from 'rxjs/operators';
Use it like this:
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(() => ( <div> <input onChange={handler} placeholder="GitHub username" /> </div> )) ) });
Now, if you start typing something into our input field, the values [props, value]
will appear in the console.
This component will be responsible for displaying the user whose name we will pass on to it. It will receive the value
from the App
component and translate it into an AJAX request.
All of this is based on the great GitHub Cards project. Most of the code, especially styles, are copied or adapted.
Create a folder src/User
. Create a User.css
file in it and copy this code into it.
And copy this code into the src/User/Component.js
file.
This component simply fills the template with data from the call to the GitHub API.
Now this component is “stupid” and we are not on the way with it, let's make a “smart” component.
Here is 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 => ( <h3>{user}</h3> )) ); return getUser$; }); export default User;
We defined the User
as componentFromStream
, which returns an Observable prop$
object converting incoming properties to <h3>
.
Our User
will receive new values each time a key is pressed on the keyboard, but we do not need this behavior.
When the user starts typing, debounceTime(1000)
will skip all events that last less than one second.
We expect the user
object to be passed as props.user
. The pluck operator takes the specified field from the object and returns its value.
Here we will make sure that the user
passed and is not an empty string.
Make the user
tag <h3>
.
Let's src/index.js
back to src/index.js
and import the User
component:
import User from './User';
Pass the value value
as user
parameter:
return combineLatest(prop$, value$).pipe( tap(console.warn), map(([props, value]) => ( <div> <input onChange={handler} placeholder="GitHub username" /> <User user={value} /> </div> )) );
Now our value is displayed on the screen with a delay of one second.
Not bad, now we need to get information about the user.
GitHub provides an API for getting user information: https://api.github.com/users/${user} . We can easily write a helper function:
const formatUrl = user => `https://api.github.com/users/${user}`;
And now we can add a map(formatUrl)
after the filter
:
const getUser$ = prop$.pipe( debounceTime(1000), pluck('user'), filter(user => user && user.length), map(formatUrl), // <-- map(user => ( <h3>{user}</h3> )) );
And now instead of the username on the screen displays the URL.
We need to make a request! switchMap
and ajax
come to the switchMap
.
This operator is ideal for switching between multiple observables.
Let's say the user typed a name, and we will make a request inside switchMap
.
What happens if the user enters something else before the response from the API comes? Should we be worried about previous queries?
Not.
The switchMap
operator switchMap
cancel the old request and switch to the new one.
RxJS provides its own ajax
implementation that works great with switchMap
!
We import both operators. My code looks like this:
import { ajax } from 'rxjs/ajax'; import { debounceTime, filter, map, pluck, switchMap } from 'rxjs/operators';
And we use them like this:
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$; });
The switchMap
operator switches from our input field to an AJAX request. When the answer comes, it passes it to our "stupid" component.
And here is the result!
Try entering a non-existent username.
Our application is broken.
With the catchError
operator catchError
we can display a sane answer, instead of quietly breaking.
We import:
import { catchError, debounceTime, filter, map, pluck, switchMap } from 'rxjs/operators';
And insert it at the end of our AJAX request:
switchMap(url => ajax(url).pipe( pluck('response'), map(Component), catchError(({ response }) => alert(response.message)) ) )
Not bad, but of course you can do better.
Create a file src/Error/index.js
with the contents:
import React from 'react'; const Error = ({ response, status }) => ( <div className="error"> <h2>Oops!</h2> <b> {status}: {response.message} </b> <p>Please try searching again.</p> </div> ); export default Error;
It will nicely display the response
and status
our AJAX request.
We import it into User/index.js
, and at the same time the operator of
from RxJS:
import Error from '../Error'; import { of } from 'rxjs';
Remember that the function passed to componentFromStream
must return observable. We can achieve this using the operator of
:
ajax(url).pipe( pluck('response'), map(Component), catchError(error => of(<Error {...error} />)) )
Now our UI looks much better:
It's time to introduce state management. How else can a load indicator be implemented?
What if the place setState
we will use BehaviorSubject
?
The Recompose documentation suggests the following:
Instead of setState (), combine multiple threads
Ok, you need two new imports:
import { BehaviorSubject, merge, of } from 'rxjs';
The BehaviorSubject
object will contain the download status, and merge
will associate it with the component.
Inside the componentFromStream
:
const User = componentFromStream(prop$ => { const loading$ = new BehaviorSubject(false); const getUser$ = ...
The BehaviorSubject
object is initialized with an initial value, or "state". Once we do nothing, until the user starts typing the text, we initialize it to false
.
We will change the state of loading$
using the tap
operator:
import { catchError, debounceTime, filter, map, pluck, switchMap, tap // <--- } from 'rxjs/operators';
We will use it like this:
const loading$ = new BehaviorSubject(false); const getUser$ = prop$.pipe( debounceTime(1000), pluck('user'), filter(user => user && user.length), map(formatUrl), tap(() => loading$.next(true)), // <--- switchMap(url => ajax(url).pipe( pluck('response'), map(Component), tap(() => loading$.next(false)), // <--- catchError(error => of(<Error {...error} />)) ) ) );
Immediately before the switchMap
and the AJAX request, we pass true
value to loading$
, and after a successful response, false
.
And now we just connect loading$
and getUser$
.
return merge(loading$, getUser$).pipe( map(result => (result === true ? <h3>Loading...</h3> : result)) );
Before we look at work, we can import the delay
operator so that the transitions are not too fast.
import { catchError, debounceTime, delay, filter, map, pluck, switchMap, tap } from 'rxjs/operators';
Add delay
before the map(Component)
:
ajax(url).pipe( pluck('response'), delay(1500), map(Component), tap(() => loading$.next(false)), catchError(error => of(<Error {...error} />)) )
Result?
Everything :)
Source: https://habr.com/ru/post/419559/
All Articles