📜 ⬆️ ⬇️

React.js: build an isomorphic / universal application from scratch. Part 3: we add authorization and data exchange with API


Please login


This is the third and final part of the article about developing an isomorphic React.js application from scratch. Parts one and two .


In this part we:



1. Add redux-dev-tools


This is a very handy library that simplifies the development process. With it, you can see in real time the contents of the global state, as well as its changes. Additionally, redux-dev-tools allows you to roll back the latest changes in the global state, which is convenient during testing and debugging. To us, it will add visibility and make the learning process more interactive and transparent.


1.1. Install the necessary packages


npm i --save-dev redux-devtools redux-devtools-log-monitor redux-devtools-dock-monitor 

1.2. Implement the component responsible for rendering the redux-dev-tools panel


src / components / DevTools / DevTools.jsx


 import React from 'react'; import { createDevTools } from 'redux-devtools'; import LogMonitor from 'redux-devtools-log-monitor'; import DockMonitor from 'redux-devtools-dock-monitor'; export default createDevTools( <DockMonitor toggleVisibilityKey='ctrl-h' changePositionKey='ctrl-q'> <LogMonitor /> </DockMonitor> ); 

src / components / DevTools / index.js


 import DevTools from './DevTools'; export default DevTools; 

1.3. "Comb" rootReducer


In the second part, we put the creation of the root reducer in the configureStore , which is not entirely correct, since this is not his area of ​​responsibility. Let's do a little refactoring and transfer it to redux / reducers / index.js .


redux / reducers / index.js


 import { combineReducers } from 'redux'; import counterReducer from './counterReducer'; export default combineReducers({ counter: counterReducer }); 

From the redux-dev-tools documentation it follows that we need to make changes to configureStore . Recall that the redux-dev-tools tools are needed only for development, so we repeat the maneuver described earlier:


  1. rename configureStore.js to configureStore.prod.js ;
  2. implement configureStore.dev.js ;
  3. we implement configureStore.js , which, depending on the system landscape, uses either configureStore.prod.js or configureStore.dev.js .

 mv redux/configureStore.js redux/configureStore.prod.js 

src / redux / configureStore.prod.js


 import { applyMiddleware, createStore } from 'redux'; import thunk from 'redux-thunk'; import rootReducer from './reducers'; export default function (initialState = {}) { return createStore(rootReducer, initialState, applyMiddleware(thunk)); } 

Implement configureStore.dev.js with DevTools and hot-reload support.


src / redux / configureStore.dev.js


 import { applyMiddleware, createStore, compose } from 'redux'; import thunk from 'redux-thunk'; import DevTools from 'components/DevTools'; import rootReducer from './reducers'; export default function (initialState = {}) { const store = createStore(rootReducer, initialState, compose( applyMiddleware(thunk), DevTools.instrument() ) ); if (module.hot) { module.hot.accept('./reducers', () => store.replaceReducer(require('./reducers').default) ); } return store; } 

ConfigureStore Entry Point


src / redux / configureStore.js


 if (process.env.NODE_ENV === 'production') { module.exports = require('./configureStore.prod'); } else { module.exports = require('./configureStore.dev'); } 

All is ready! Restart the webpack-dev-server and nodemon , open the browser and see that a panel appears on the right that reflects the contents of the global state. Now open the page with the counters and click on the ReduxCounter . At the same time, with each click, we see how actions arrive in the redux queue and the global state changes. By clicking on Revert , we can undo the last action, and by clicking on Commit - approve all actions and clear the current command queue.


Note: after adding redux-dev-tools , you may see a message in the console: "React attempted to be reused ..." . This means that the server and client parts of the application render different content. This is very bad and should be avoided in your applications. However, in this case, the culprit is redux-dev-tools , which we will not use in production anyway, so you can make an exception and safely ignore the problem report.


Update: thanks to users of gialdeyn and Lerayne , you can repair SSR with redux-dev-tools in the following way


src / server.js


 <div id="react-view">${componentHTML}</div> +++ <div id="dev-tools"></div> 

src / client.js


 +++ import DevTools from './components/DevTools'; ... ReactDOM.render(component, document.getElementById('react-view')); +++ ReactDOM.render(<DevTools store={store} />, document.getElementById('dev-tools')); 

2. Add new functionality


Implement the following script


  1. The user clicks the "Request time" button.
  2. We show the download indicator, the button becomes inactive to avoid unwanted repeated requests.
  3. The application makes a request to the API.
  4. The application receives a response from the API and saves the received data to a global state.
  5. The download indicator disappears, the button becomes active again; we display the data to the user.

This is a fairly voluminous task. To focus on its individual parts, we first implement items 1, 2 and 5, and for 3 and 4 we will make a stub.


2.1. Add actions


After clicking on the "Request time" button, we must successively:


  1. change the loading value from false to true ;
  2. make a request;
  3. after receiving the response, return the loading value from true to false back and save either the received data or error information.

 export const TIME_REQUEST_STARTED = 'TIME_REQUEST_STARTED'; export const TIME_REQUEST_FINISHED = 'TIME_REQUEST_FINISHED'; export const TIME_REQUEST_ERROR = 'TIME_REQUEST_ERROR'; function timeRequestStarted() { return { type: TIME_REQUEST_STARTED }; } function timeRequestFinished(time) { return { type: TIME_REQUEST_FINISHED, time }; } function timeRequestError(errors) { return { type: TIME_REQUEST_ERROR, errors }; } export function timeRequest() { return (dispatch) => { dispatch(timeRequestStarted()); return setTimeout(() => dispatch(timeRequestFinished(Date.now())), 1000); //  network latency :) }; } 

Here we arrange each action in the form of a small function that will change the global state, and timeRequest is a combination of these functions, which fully describes our script. That is what we will call from our component.


2.2. Update the page code over time


Add the react-bootstrap-button-loader button with support for the download indicator on the TimePage page and teach it to call the timeRequest function by clicking.


Install the react-bootstrap-button-loader package


 npm i --save react-bootstrap-button-loader 

Add a button and click handler


src / components / TimePage / TimePage.jsx


 import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; import PageHeader from 'react-bootstrap/lib/PageHeader'; import Button from 'react-bootstrap-button-loader'; import { timeRequest } from 'redux/actions/timeActions'; const propTypes = { dispatch: PropTypes.func.isRequired }; class TimePage extends Component { constructor() { super(); this.handleClick = this.handleClick.bind(this); } handleClick() { this.props.dispatch(timeRequest()); } render() { return ( <div> <PageHeader>Timestamp</PageHeader> <Button onClick={this.handleClick}>!</Button> </div> ); } } TimePage.propTypes = propTypes; export default connect()(TimePage); 

Note that we had to use connect from react-redux in order for our button to have access to the dispatch function to change the global state.


It's time to look at the results of works: open the "Time" page in the browser, click on the "Request" button. The interface is not doing anything yet, but in redux-dev-tools we now see how the actions that we have recently implemented are launched.


It is time to revive the interface. Start by implementing logic to update the global state.


2.3. We realize a reducer


src / redux / reducers / timeReducer.js


 import { TIME_REQUEST_STARTED, TIME_REQUEST_FINISHED, TIME_REQUEST_ERROR } from 'redux/actions/timeActions'; const initialState = { time: null, errors: null, loading: false }; export default function (state = initialState, action) { switch (action.type) { case TIME_REQUEST_STARTED: return Object.assign({}, state, { loading: true, errors: null }); case TIME_REQUEST_FINISHED: return { loading: false, errors: null, time: action.time }; case TIME_REQUEST_ERROR: return Object.assign({}, state, { loading: false, errors: action.errors }); default: return state; } } 

An important point that should not be forgotten: according to the redux specification , we have no right to change the state passed to us and must return either its own or a new object. To form a new object, I use Object.assign , which takes the original object and applies the changes I need to it.


Ok, now let's add a new reducer to the root reducer.


src / redux / reducers / index.js


 +++ import timeReducer from './timeReducer'; export default combineReducers({ counter: counterReducer, +++ time: timeReducer }); 

Open the browser again and, after clearing the redux-dev-tools queue, click on the "Request" button. The interface is still not being updated, but now our actions change the global state according to the code of our reducer, which means that under the hood, all the logic works as it should. Things are easy - we will revive the interface.


2.4. Update the code of the page "Time"


src / components / TimePage / TimePage.jsx


 const propTypes = { dispatch: PropTypes.func.isRequired, +++ loading: PropTypes.bool.isRequired, +++ time: PropTypes.any }; class TimePage extends Component { ... render() { +++ const { loading, time } = this.props; ... --- <Button onClick={this.handleClick}>!</Button> +++ <Button loading={loading} onClick={this.handleClick}>!</Button> +++ {time && <div>Time: {time}</div>} </div> ); } } +++ function mapStateToProps(state) { +++ const { loading, time } = state.time; +++ return { loading, time }; +++ } --- export default connect()(TimePage); +++ export default connect(mapStateToProps)(TimePage); 

Go to the browser, again click on the "Request" button and make sure that everything works according to our script.


It is time to replace the stub with a real backend .


3. Add interaction with backend and authorization


Note: for this example, I use a very simple backend , developed by me on rails . It is available at https://redux-oauth-backend.herokuapp.com and contains only one / test / test method, which returns the server timestamp if the user is authorized, otherwise 401 error. The source code for backend 'can be found here: https://github.com/yury-dymov/redux-oauth-backend-demo . There, I use gem devise for authentication, which is de facto the standard for solving similar problems for rails and gem devise_token_auth , which adds a devise to the authentication mechanism of Bearer Token-based Authentication . Nowadays, this mechanism is most often used when developing secure APIs.


From the client side, we have much to do:


  1. From the previous article, I still have a small debt: the global state after Server Side Rendering is not transferred or used by the client. We will fix it now.
  2. Add a redux-oauth library to the project, which is responsible for authorization by the frontend , and configure it for an isomorphic script.
  3. Replace the stub with the code that will actually perform requests to the API.
  4. Add the "Login to system" and "Logout" buttons.

3.1. Pass a global state


The mechanism is very simple:


  1. After the server has done all the work and has generated content for the client, we call the getState function, which returns the current global state. Next, we transfer the content and global state to our HTML template and give the resulting page to the client.
  2. Client-side JavaScript reads the global state directly from the global window object and passes it to the configureStore as the initialState .

src / server.js


 +++ const state = store.getState(); --- return res.end(renderHTML(componentHTML)); +++ return res.end(renderHTML(componentHTML, state)); ... --- function renderHTML(componentHTML, initialState) { +++ function renderHTML(componentHTML, initialState) { <link rel="stylesheet" href="${assetUrl}/public/assets/styles.css"> +++ <script type="application/javascript"> +++ window.REDUX_INITIAL_STATE = ${JSON.stringify(initialState)}; +++ </script> </head> 

src / client.js


 +++ const initialState = window.REDUX_INITIAL_STATE || {}; --- const store = configureStore(); +++ const store = configureStore(initialState); 

As you can see from the code, I pass the global state in the variable REDUX_INITIAL_STATE.


3.2. Add authorization


Install redux-oauth


Note: we use redux-oauth for an isomorphic script, but it also supports client-side only . Configuration examples for various cases and demos can be found on the library website .


Note 2: redux-oauth uses cookies for authorization, since the local storage mechanism is not suitable for an isomorphic scenario.


 npm i --save redux-oauth cookie-parser 

Activate cookieParser plugin for express


src / server.js


 +++ import cookieParser from 'cookie-parser'; const app = express(); +++ app.use(cookieParser()); 

We configure redux-oauth for server part of the application


src / server.js


 +++ import { getHeaders, initialize } from 'redux-oauth'; app.use((req, res) => { const store = configureStore(); +++ store.dispatch(initialize({ +++ backend: { +++ apiUrl: 'https://redux-oauth-backend.herokuapp.com', +++ authProviderPaths: { +++ github: '/auth/github' +++ }, +++ signOutPath: null +++ }, +++ currentLocation: req.url, +++ cookies: req.cookies })).then(() => match({ routes, location: req.url }, (error, redirectLocation, renderProps) => { ... const state = store.getState(); +++ res.cookie('authHeaders', JSON.stringify(getHeaders(state)), { maxAge: Date.now() + 14 * 24 * 3600 * 1000 }); return res.end(renderHTML(componentHTML, state)); })); 

Many interesting things happen here:


  1. We have to call the initialize function from redux-oauth , which will pass the current URL , cookies and configuration: the address of the API and the OAuth providers used.
  2. If an authentication token is found in the transmitted cookies , the library will check its validity at the backend and, if successful, save the user information in a global state. Note that further application code will be executed only after the initialize has completed .
  3. Before sending HTML to the client, we use the res.cookie method. This method tells express that you need to add a SetCookie header to the HTTP response, in which you need to send the updated authorization token. This is a very important step: the new authorization token will be saved in the browser cookie immediately after it receives a response from the server. Thus, we guarantee that authorization will not break even in cases when client-side JavaScript did not have time to download, initialize, or run with an error.

According to the documentation, we also need to add redux reducer-oauth to the root reducer.


src / redux / reducers / index.js


 +++ import { authStateReducer } from 'redux-oauth'; export default combineReducers({ +++ auth: authStateReducer, 

3.3. Replace the stub in timeActions.js


src / redux / actions / timeActions.js


 import { fetch, parseResponse } from 'redux-oauth'; export function timeRequest() { return (dispatch) => { dispatch(timeRequestStarted()); --- return setTimeout(() => dispatch(timeRequestFinished(Date.now())), 1000); //  network latency :) +++ return dispatch(fetch('https://redux-oauth-backend.herokuapp.com/test/test')) +++ .then(parseResponse) +++ .then(({ payload }) => dispatch(timeRequestFinished(payload.time))) +++ .catch(({ errors }) => dispatch(timeRequestError(errors))); }; } 

The fetch function from redux-oauth is an extended function from the isomorphic-fetch package. According to the documentation, it is necessary to call it via dispatch , since in this case it will have access to a global state, from which it can read the authorization token and send it along with the request. If the fetch function is used for an arbitrary HTTP request, rather than a request to the API, then the authorization token will not be used, that is, the algorithm for its execution will be 100% identical to the algorithm for the isomorphic-fetch execution.


Note: isomorphic-fetch is a library that can make HTTP requests from both the browser and the Node environment.


Open the browser and once again click on the "Request" button of the "Time" page. Well, we no longer see the current timestamp , but redux-dev-tools has information about a 401 error. It is not surprising, because we have to be authorized in order for the API to return something to us.


3.4. Add the "Login" and "Exit" buttons


As a rule, an authorized user has more opportunities to work with the system than a guest, otherwise what is the point of authorization?


From a technical point of view, this means that many components may look and behave differently depending on whether the user has logged on or not.


I am an ardent supporter of the DRY principle (don't repeat yourself) , so we will write a small helper.


src / redux / models / user.js


 export function isUserSignedIn(state) { return state.auth.getIn(['user', 'isSignedIn']); } 

Implement the button "Login"


src / components / AuthButtons / OAuthButton.jsx


 import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; import { oAuthSignIn } from 'redux-oauth'; import Button from 'react-bootstrap-button-loader'; import { isUserSignedIn } from 'redux/models/user'; const propTypes = { dispatch: PropTypes.func.isRequired, loading: PropTypes.bool.isRequired, provider: PropTypes.string.isRequired, userSignedIn: PropTypes.bool.isRequired }; class OAuthButton extends Component { constructor(props) { super(props); this.handleClick = this.handleClick.bind(this); } handleClick() { const { dispatch, provider } = this.props; dispatch(oAuthSignIn({ provider })); } render() { const { loading, provider, userSignedIn } = this.props; if (userSignedIn) { return null; } return <Button loading={loading} onClick={this.handleClick}>{provider}</Button>; } } OAuthButton.propTypes = propTypes; function mapStateToProps(state, ownProps) { const loading = state.auth.getIn(['oAuthSignIn', ownProps.provider, 'loading']) || false; return { userSignedIn: isUserSignedIn(state), loading }; } export default connect(mapStateToProps)(OAuthButton); 

This button will be displayed only if the user has not yet logged in.


Implement the "Logout" button


src / components / AuthButtons / SignOutButton.jsx


 import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; import { signOut } from 'redux-oauth'; import Button from 'react-bootstrap-button-loader'; import { isUserSignedIn } from 'redux/models/user'; const propTypes = { dispatch: PropTypes.func.isRequired, userSignedIn: PropTypes.bool.isRequired }; class SignOutButton extends Component { constructor(props) { super(props); this.handleClick = this.handleClick.bind(this); } handleClick() { const { dispatch } = this.props; dispatch(signOut()); } render() { if (!this.props.userSignedIn) { return null; } return <Button onClick={this.handleClick}></Button>; } } SignOutButton.propTypes = propTypes; function mapStateToProps(state) { return { userSignedIn: isUserSignedIn(state) }; } export default connect(mapStateToProps)(SignOutButton); 

This button will be displayed only if the user is already logged in.


src / components / AuthButtons / index.js


 import OAuthButton from './OAuthButton'; import SignOutButton from './SignOutButton'; export { OAuthButton, SignOutButton }; 

I will add authorization to the HelloWorldPage page.


src / components / HelloWorldPage / HelloWorldPage.jsx


 +++ import { OAuthButton, SignOutButton } from 'components/AuthButtons'; +++ <h2></h2> +++ <OAuthButton provider='github' /> +++ <SignOutButton /> 

It is time to enjoy the results of our work. We click on the "Login" button, use our github account for authorization and ... we are in the system! The "Enter" button has disappeared, but the "Exit" button has appeared. Check that the session is saved, for this we reload the page. The "Exit" button has not disappeared, and in redux-dev-tools you can find user information Fine! While everything works. Go to the "Time" page, click on the "Request" button and see that the timestamp is displayed - this is the server returned the data to us.


This could be finished, but we need to "polish" our application.


4. "Grind" the application


So what can be improved:


  1. Links to the "Time" page should be displayed only for authorized users.
  2. If the user entered the address of the protected page in the browser, we will redirect it to the page with authorization (in our case, HelloWorldPage ).
  3. If a user is logged out, we must remove his data from the global state.

4.1. We remove links to inaccessible pages.


src / components / App / App.jsx


 +++ import { connect } from 'react-redux'; +++ import { isUserSignedIn } from 'redux/models/user'; const propTypes = { +++ userSignedIn: PropTypes.bool.isRequired, ... }; ... +++ {this.props.userSignedIn && ( <LinkContainer to='/time'> <NavItem></NavItem> </LinkContainer> +++ )} ... +++ function mapStateToProps(state) { +++ return { userSignedIn: isUserSignedIn(state) }; +++ } --- export default App; +++ export default connect(mapStateToProps)(App); 

Open the browser and see that the link to the "Time" page is still available, go to the HelloWorldPage page, click on the "Exit" button - and the link is gone.


4.2. Restrict access to secure pages


As we remember, the react-router library is responsible for the correspondence between the URL and the page to be rendered, and the configuration of the paths is in the routes.jsx file. We need to add the following logic: if the user is unauthorized and requested a secure page, then redirect him to HelloWorldPage .


To get information about the user, we need to send a link to the global state storage in routes.jsx


src / server.js


 --- .then(() => match({ routes, location: req.url }, (error, redirectLocation, renderProps) => { +++ .then(() => match({ routes: routes(store), location: req.url }, (error, redirectLocation, renderProps) => { 

src / client.js


 <Router history={browserHistory}> --- {routes} +++ {routes(store)} </Router> 

src / routes.jsx


 import { isUserSignedIn } from 'redux/models/user'; function requireAuth(nextState, transition, cb) { setTimeout(() => { if (!isUserSignedIn(store.getState())) { transition('/'); } cb(); }, 0); } let store; export default function routes(storeRef) { store = storeRef; return ( <Route component={App} path='/'> <IndexRoute component={HelloWorldPage} /> <Route component={CounterPage} path='counters' /> <Route component={TimePage} path='time' onEnter={requireAuth} /> </Route> ); } 

Testing:


  1. Make sure that we are logged in;
  2. Enter http: // localhost: 3001 / time into the address bar of the browser, press "Enter" and as expected we will see the "Time" page;
  3. Log out;
  4. Once again, enter http: // localhost: 3001 / time in the address bar of the browser and press "Enter" - this time we were redirected to the "HelloWorldPage" page - everything works!

Note: the requireAuth function uses zero delay setTimeout , which at first glance makes no sense. This is done on purpose, as it allows you to bypass the bug in one of the popular browsers.


4.3. Clearing user data from global state


src / redux / reducers / timeReducer.js


 +++ import { SIGN_OUT } from 'redux-oauth'; +++ case SIGN_OUT: +++ return initialState; default: return state; 

If action SIGN_OUT is received , then all timeReducer reducer data will be replaced with initialState , that is, with default values. The same technique must be implemented for all other reducers that contain user data.


5. Bonus: Server-Side API Requests


The redux-oauth library supports Server Side API requests , that is, during the rendering process, the server can access the API for data. This has many advantages:



Note : yes, search engines will not be authorized, but some API services will be able to return data for unauthorized users with some restrictions. redux-oauth is suitable for such scenarios.


We implement a small Proof of Concept .


API


src/server.js


 +++ import { timeRequest } from './redux/actions/timeActions'; ... return store.dispatch(initialize({ backend: { apiUrl: 'https://redux-oauth-backend.herokuapp.com', authProviderPaths: { github: '/auth/github' }, signOutPath: null }, cookies: req.cookies, currentLocation: req.url, })) +++ .then(() => store.dispatch(timeRequest())) .then(() => match({ routes: routes(store), location: req.url }, (error, redirectLocation, renderProps) => { 

, initialize redux-oauth backend , , timeRequest . .


, , "" F5. timestamp , "" . Dev Tools , Network , , API . , .


: API , .


src/redux/actions/timeActions.js


 --- return (dispatch) => { +++ return (dispatch, getState) => { +++ if (!isUserSignedIn(getState())) { +++ return Promise.resolve(); +++ } 

, getState , . , .


6.


- React.js . , !


, .


github — https://github.com/yury-dymov/habr-app/tree/v3


Ps , , . Thank you in advance!


')

Source: https://habr.com/ru/post/310952/


All Articles