App Walkthrough - Developer Experience
Having seen how our React-powered front end application is to look and behave, in this video we will take a look at some of the JavaScript code that powers the site.
Throughout the rest of this series we will cover each part of this project in much greater depth, but for the purposes of this video we will follow the login process from a high level, in order to understand how the major pieces of this puzzle fit together.
A Word On Boilerplate
Before we begin I want to point out that I based this project on the Corey House React Slingshot starter kit. You are, of course, entitled to your opinion of starter kits / React boilerplate, but hear me out on why I chose this one, and what it means for this project.
When I started writing this course there were, at the very least, tens of available boilerplates. My aim was not to learn the intricacies of WebPack, nor the myriad other libraries needed to get an ES6 project just to load up and work. The now popular Create React App was not yet a thing. At least, not publicly.
I went with the React Slingshot as it worked, and it contained a whole bunch of things that I wanted - React (of course), Redux, React Router, and importantly, a decent user guide and notes on how to put this thing into a production environment. You see, my aim for this was to use it as the foundations for the third iteration of CodeReviewVideos front end.
There's also a nice starter application which demonstrates one way of combining all these pieces together that results in a working web app.
Over the lifetime of working on this codebase, the original provided folder structure - particularly for src/
- has changed. Therefore, as we begin coding, we will remove the vast majority of the implementation from the boilerplate, and roll our own. Really, the only interesting part for me is the shortcut to a working Babel / Webpack / JS build process.
Walking Through The App
With the boilerplate discussion out of the way, let's start proper by looking at some code.
The boilerplate provides a key piece to kickstarting this puzzle: tying the JS and HTML together.
You are free to dive into the webpack.config.dev.js
and webpack.config.prod.js
files yourself. We have no need to modify either at this stage.
As part of the config, it is specified that our HTML template will be from index.ejs
, and our JS will come from index.js
:
<!-- /src/index.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Symfony 3 + React Registration Example</title>
<script type="text/javascript" src="https://js.stripe.com/v2/"></script>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/latest/css/bootstrap.min.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
React needs a div
to work with. In our case, this will the the div
with the id
of app
.
We add in a reference to the Bootstrap's styling - but of course you are free to use whatever you like here. A better way (in my opinion) would be to build your CSS into your own dist
bundle, but that's not really something we will cover in this tutorial.
Tying to this is the index.js
file:
import React from 'react';
import {render} from 'react-dom';
import {Provider} from 'react-redux';
import {Router, browserHistory} from 'react-router';
import routes from './routes';
import configureStore from './store/configureStore';
import './styles/styles.scss'; // Yep, that's right. You can import SASS/CSS files too! Webpack will run the associated loader and plug this into the page.
import {syncHistoryWithStore} from 'react-router-redux';
import DevTools from './containers/DevTools';
import "whatwg-fetch";
require('./favicon.ico'); // Tell webpack to load favicon.ico
const store = configureStore();
const devTools = () => process.env.NODE_ENV !== 'production' ? <DevTools /> : '';
// Create an enhanced history that syncs navigation events with the store
const history = syncHistoryWithStore(browserHistory, store);
render(
<Provider store={store}>
<div>
<Router history={history} routes={routes} />
{devTools()}
</div>
</Provider>, document.getElementById('app')
);
There are lots of things happening here. Fortunately, we don't need to concern ourselves with the vast majority of this at this stage.
What is important is that our div
with the id
of app
is used as the container that React will display its contents into. The Provider
component is key to enabling Redux in our application, and the store
is what will hold our application's state.
How these pieces fit together requires a deeper dive than this video has in scope, so for the moment, we will brush over these topics and continue. The links provided above do go deeper should you wish to do so at this point. My opinion is that these concepts begin to make more sense once you have seen what they enable.
Wrapped in the Provider
is the React Router setup - which takes in our routes
, more on this momentarily.
Also, we only want the DevTools
component adding if we are in any other environment than production
, so the outcome of the call to devTools()
will either be to add the DevTools
component, or this will be ignored in prod. Our DevTools
implementation is what enables seeing the state
and action
inside our browser's console, and also enables a handy DockMonitor
, which you may prefer.
Adding Our Own Routes
Most of the code so far has been your typical boilerplate - it's going to be the same whether you're working on an Accounting system, or a CRM, some custom sales tool, or any other typical web app.
Where things start to differ is in the routing.
In our case, our finished routing file will look as follows:
// /src/routes.js
import React from 'react';
import {Route, IndexRoute} from 'react-router';
import {routerActions} from 'react-router-redux';
import {UserAuthWrapper} from 'redux-auth-wrapper';
import App from './containers/App';
import AboutPage from './components/AboutPage.react.js';
import HomePage from './components/HomePage.react';
import LoginPage from './containers/LoginPage';
import LogoutPage from './containers/LogoutPage';
import ProfileContainer from './containers/ProfileContainer';
import RegistrationContainer from './containers/RegistrationContainer';
import NotFoundPage from './components/NotFoundPage.react.js';
// Redirects to /login by default
const userIsAuthenticated = UserAuthWrapper({ // eslint-disable-line babel/new-cap
authSelector: (state) => state.auth, // how to get the user state
predicate: (user) => user.isAuthenticated, // function to run against the user state to determine is authenticated
redirectAction: routerActions.replace, // the redux action to dispatch for redirect
wrapperDisplayName: 'UserIsAuthenticated' // a nice name for this auth check,
});
export default (
<Route path="/" component={App}>
<IndexRoute component={HomePage}/>
<Route path="about" component={AboutPage}/>
<Route path="login" component={LoginPage}/>
<Route path="logout" component={LogoutPage}/>
<Route path="profile" component={userIsAuthenticated(ProfileContainer)}/>
<Route path="register" component={RegistrationContainer}/>
<Route path="*" component={NotFoundPage}/>
</Route>
);
This file dictates what URLs will be available in our app, and when accessed, what component to show.
At this point, I would advise you follow the React Router tutorial if any of this looks confusing to you. We will go deeper into this for each of our components, but for now, just be aware that this exists.
As we are following the login
flow, we can see that when the path of /login
is hit, our application will load the App
container (think: smart component), and also the LoginPage
container.
Containers and Components
When working with React, you will frequently come across the term 'component'.
A component is a way of splitting up your user interface into smaller, often reusable chunks. This brings with it the benefit of making each piece of your user interface more easy to understand, and test.
There is a concept on top of this around 'smart' vs 'dumb' components.
Somewhere along the way, someone took offence with the term 'dumb' component and offered up an alternative vernacular of 'component' and 'container'.
The long and short of this is that a 'component' is unaware of the world outside itself. If it needs to do something, it should be told how to do it. An example of this would be the login form. It shouldn't know how to log in, it should instead be provided with a function (via props
) that it should call whenever the user clicks the 'submit' button.
A 'container' is still a 'component'. Only, a container is aware of a bigger picture - but not necessarily the whole picture. In the login example, the LoginPage
container defines the function to run when the LoginForm
component submit event is triggered. Think of the 'container' components as the glue.
LoginPage
and LoginForm
Knowing this, we can take a look at the LoginPage
:
// /src/containers/LoginPage.js
import React, {PropTypes, Component} from 'react';
import {connect} from 'react-redux';
import {withRouter} from 'react-router';
import LoginForm from '../components/LoginForm.react';
import * as types from '../constants/ActionTypes';
import '../styles/login-page.css';
class LoginPage extends Component {
componentWillReceiveProps(newProps) {
if (newProps.pageState.auth.isAuthenticated) {
this.props.router.replace('/');
}
}
doLogin(formData) {
this.props.dispatch({
type: types.LOGIN__REQUESTED,
payload: {
username: formData.username,
password: formData.password
}
});
}
render() {
return (
<div>
<LoginForm
onSubmit={this.doLogin.bind(this)}
isSubmitting={!!this.props.pageState.request.sendingRequest}
/>
</div>
);
}
}
LoginPage.propTypes = {
dispatch: PropTypes.func.isRequired,
pageState: PropTypes.object.isRequired,
router: PropTypes.object.isRequired
};
const mapStateToProps = (state) => {
return {
pageState: state
};
};
export default connect(
mapStateToProps
)(withRouter(LoginPage));
The LoginPage
component is our 'container'. It defines the doLogin
function - which when called will dispatch a Redux action containing the user supplied data from the LoginForm
component.
The LoginPage
component is also aware of whether any HTTP requests are currently in progress, and passes that information into our LoginForm
also - useful should we wish to display a loading spinner.
Whilst this component is 'smart', it is still doing as little as possible.
The LoginForm
by comparison is simpler:
// /src/components/LoginForm.react.js
import React from 'react';
import {Field, reduxForm} from 'redux-form';
import FormField from './FormField';
import * as FORMS from '../constants/Forms';
const LoginForm = (props) => {
const {handleSubmit} = props;
return (
<form className="form-signin" onSubmit={handleSubmit(props.onSubmit)}>
<h2 className="form-signin-heading">Welcome back</h2>
<Field component={FormField}
name="username"
id="username"
type="text"
placeholder="Username or Email Address"
required="required"
/>
<Field component={FormField}
name="password"
id="password"
type="password"
placeholder="Password"
required="required"
/>
<button className="btn btn-lg btn-primary btn-block"
disabled={props.pristine || props.isSubmitting}
type="submit">
{props.isSubmitting ? <i className="fa fa-spin fa-spinner" /> : null} Log in
</button>
</form>
);
};
LoginForm.propTypes = {
handleSubmit: React.PropTypes.func.isRequired,
onSubmit: React.PropTypes.func.isRequired,
pristine: React.PropTypes.bool.isRequired,
isSubmitting: React.PropTypes.bool.isRequired
};
// Decorate the form component
export default reduxForm({
form: FORMS.LOGIN // a unique name for this form
})(LoginForm);
We're using Redux Form here to make working with forms that bit easier. We expect to be given a function to call when the form is submitted, and if the form is submitting then we want to display a little spinner icon, and make sure the button is marked as disabled
. Aside from that, not a great deal to see here.
Starting The Saga
Where things become more interesting is in what happens once an action is dispatched.
A big problem once you get past the basic tutorials for Redux is what happens when functions aren't pure. In other words, if we can run a function many times with the same parameters - but get different outcomes, such as if a call to your Symfony 3 API fails because your server is offline - then how do we handle this without creating one giant headache?
Enter Redux Saga.
Remember back to the LoginPage
container, we called dispatch
and passed in an object (a Redux Standard Action) which contained the information provided from the login form:
// /src/containers/LoginPage.js
doLogin(formData) {
this.props.dispatch({
type: types.LOGIN__REQUESTED,
payload: {
username: formData.username,
password: formData.password
}
});
}
Using Redux Sagas we can define a function to 'watch' for actions of a given type
, and then do something appropriate:
/src/sagas/auth.saga.js
export function *watchLogin() {
yield* takeLatest(types.LOGIN__REQUESTED, doLogin);
}
We must also setup Redux Sagas as part of the configuration for our store. This is not shown here, but will be covered in future videos as appropriate. For now, just be aware that a central rootSaga
maintains an array of all the watch
functions in our application.
In this instance, when an action is dispatched with the type of types.LOGIN__REQUESTED
, then the doLogin
generator function will be called:
/src/sagas/auth.saga.js
export function *doLogin(action) {
try {
const {username, password} = action.payload;
yield put({
type: types.REQUEST__STARTED,
payload: {
requestFrom: REQUESTS.REQUEST__DOLOGIN__SAGA
}
});
const responseBody = yield call(api.login, username, password);
if (responseBody.token === undefined) {
throw new Error(MESSAGES.UNABLE_TO_FIND_TOKEN_IN_LOGIN_RESPONSE);
}
yield put({
type: types.LOGIN__SUCCEEDED,
payload: {
idToken: responseBody.token
}
});
} catch (e) {
yield put({
type: types.LOGIN__FAILED,
payload: {
message: e.message,
statusCode: e.statusCode
}
});
} finally {
yield put({
type: types.REQUEST__FINISHED,
payload: {
requestFrom: REQUESTS.REQUEST__DOLOGIN__SAGA
}
});
}
}
Again, we will dive deeper into this function in the login videos.
Essentially we grab the username
and password
information from the action's payload
, then immediately dispatch a REQUEST__STARTED
event. Should we wish to, we could listen for these actions and then use them to determine if a request is currently in progress. If it is, maybe we wish to display a loading spinner, or disable a button, or anything really - that is the beauty of this setup.
Next, we yield
a call to the API. This pauses the generator function whilst the request takes place, continuing only once a response is received. My initial concern when first reading about this was that I wasn't sure how, or when, I should restart the function. Thankfully, we needn't concern ourselves with this. Redux Saga takes care of it for us.
If the request is successful, we dispatch another action (via put
) to say the LOGIN__SUCCEEDED
.
If the request was not successful, an error may be thrown which dispatches a different kind of action - the LOGIN__FAILED
action type.
Both of these actions are completely controlled by us. That's cool, as it means we can literally do whatever we want.
It also means this function itself needn't worry about the implementation of handling either scenario - we can define two different functions to handle the individual cases, keeping our code a lot more modular, and easy to reason about.
Finally, or should I say, finally
, we always want to say the REQUEST__FINISHED
. This ensures no requests are left in limbo, leaving a loading spinner spinning forever... hey, it happened to me quite a lot during initial development ;)
Keep On Generating
We aren't done yet.
Well, we might be if we hit the LOGIN__FAILED
path. But let's pretend we didn't.
What happens next is cool. We have dispatched this new action of LOGIN__SUCCEEDED
, so what handles this?
Another watch
function, of course.
The pattern is the same, the implementation differs:
/src/sagas/auth.saga.js
export function *doLoginSucceeded(action) {
const {idToken} = action.payload;
if (idToken === undefined) {
throw new Error(MESSAGES.UNABLE_TO_FIND_TOKEN_IN_ACTION);
}
yield call(storage.save, 'id_token', idToken);
const {userId, username} = yield call(jwtDecode, idToken); // pull out the user data from the JWT
if (userId === undefined) {
throw new Error(MESSAGES.UNABLE_TO_FIND_USER_ID);
}
yield call(storage.save, 'profile', JSON.stringify({userId, username}));
yield put({
type: types.LOGIN__COMPLETED,
payload: {
userId,
username
}
});
}
export function *watchLoginSucceeded() {
yield* takeLatest(types.LOGIN__SUCCEEDED, doLoginSucceeded);
}
We want to save off some of this information to some form of local storage. Now, this may indeed be localStorage
. In my case, that's what I use. I intentionally made this storage
object easy to change - in case localStorage
is not sufficient for your use case. It's only three functions, and I will explain how to change them as we go through.
Assuming everything saves, we then dispatch the LOGIN__COMPLETED
action.
At this point this action should hit the authReducer
:
// /src/reducers/authReducer.js
import * as types from '../constants/ActionTypes';
import persistentState from '../utils/localStorage';
export default function auth(state = {
isAuthenticated: isAuthenticated(),
userId: getUserId(),
username: getUsername()
}, action) {
switch (action.type) {
case types.LOGIN__COMPLETED: {
const {userId, username} = action.payload;
return Object.assign({}, state, {
isAuthenticated: true,
userId,
username
});
}
case types.LOGIN__FAILED: {
return Object.assign({}, state, {
isAuthenticated: false,
errorMessage: action.payload.message
});
}
case types.LOGOUT__SUCCESS: {
return Object.assign({}, {
isAuthenticated: false
});
}
default: {
return state;
}
}
}
function isAuthenticated() {
return !!persistentState.getItem('id_token');
}
function getUserId() {
try {
let profile = persistentState.getItem('profile') || {};
profile = JSON.parse(profile);
return profile.userId;
} catch (e) {
return undefined;
}
}
function getUsername() {
try {
let profile = persistentState.getItem('profile');
profile = JSON.parse(profile);
return profile.username;
} catch (e) {
return undefined;
}
}
There is a little funky-ness to this. The initial state involves impure functions. Heresy. I'm open to a better implementation here, but having wracked my brain I cannot think of an implemenation here that fits the paradigm. Do shout up if you know of a better solution.
Anyway, aside from this funk, the thing of most interest to us here is in the outcome of running the reducer with this action:
case types.LOGIN__COMPLETED: {
const {userId, username} = action.payload;
return Object.assign({}, state, {
isAuthenticated: true,
userId,
username
});
}
Redux defines that we are going to have a state
. A single source of truth for our application's current setup.
However this state
is loaded - whether it be a 'hardcoded' object, or some bastardisation of this where we may have saved off to localStorage
to keep our users 'logged in' when they close the browser, we must start from an initial state.
From there, we apply actions in sequence to end up with the current state of the application.
In this case, for our auth
state, we will now be setting the values to be:
{
isAuthenticated: true,
userId,
username
}
Again, we are using some ES6 syntax here to set a key of userId
, and a value of whatever the current value of the userId
variable may be. Pretend it is 6, and our username
is 'fred', we end up with the next state for auth
being:
{
isAuthenticated: true,
userId: 6,
username: 'fred'
}
And here's the really interesting part - coming from a PHP background, at least:
What happens next is not pre-determined.
Sure, we have defined a whole structure of code to handle all the expected - and some unexpected - outcomes.
But what the next action will be - we just don't know. That's up to the user.
If you come from a largely programatic background, is in you are used to telling the computer exactly what it could do with a lifetime's supply of cholcate - then this is a whole new way of working.
Hopefully as we continue on through this course, you will come to agree.