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:
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.
npm i --save-dev redux-devtools redux-devtools-log-monitor redux-devtools-dock-monitor
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> );
import DevTools from './DevTools'; export default DevTools;
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 .
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:
mv redux/configureStore.js 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.
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
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
<div id="react-view">${componentHTML}</div> +++ <div id="dev-tools"></div>
+++ import DevTools from './components/DevTools'; ... ReactDOM.render(component, document.getElementById('react-view')); +++ ReactDOM.render(<DevTools store={store} />, document.getElementById('dev-tools'));
Implement the following script
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.
After clicking on the "Request time" button, we must successively:
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.
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.
npm i --save react-bootstrap-button-loader
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.
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.
+++ 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.
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 .
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:
The mechanism is very simple:
+++ 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>
+++ 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.
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
+++ import cookieParser from 'cookie-parser'; const app = express(); +++ app.use(cookieParser());
We configure redux-oauth for server part of the application
+++ 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:
According to the documentation, we also need to add redux reducer-oauth to the root reducer.
+++ import { authStateReducer } from 'redux-oauth'; export default combineReducers({ +++ auth: authStateReducer,
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.
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.
export function isUserSignedIn(state) { return state.auth.getIn(['user', 'isSignedIn']); }
Implement the button "Login"
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
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.
import OAuthButton from './OAuthButton'; import SignOutButton from './SignOutButton'; export { OAuthButton, SignOutButton };
I will add authorization to the HelloWorldPage page.
+++ 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.
So what can be improved:
+++ 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.
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
--- .then(() => match({ routes, location: req.url }, (error, redirectLocation, renderProps) => { +++ .then(() => match({ routes: routes(store), location: req.url }, (error, redirectLocation, renderProps) => {
<Router history={browserHistory}> --- {routes} +++ {routes(store)} </Router>
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:
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.
+++ 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.
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
+++ 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 , .
--- return (dispatch) => { +++ return (dispatch, getState) => { +++ if (!isUserSignedIn(getState())) { +++ return Promise.resolve(); +++ }
, getState , . , .
- 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