This post is written as a set of Literate Commits. The goal of this style is to show you how this program came together from beginning to end. Each commit in the project is represented by a section of the article. Click each section’s header to see the commit on Github, or check out the repository and follow along.
Front-end Join
Now that our back-end is shored up, we can turn our attention to the front-end side of the sign-up functionality.
The first thing we need to do is add the "join"
route to our router:
<Route path="join" component={AuthPageJoin}/>
We can copy the majority of the AuthPageJoin
component from the Meteor Todos project.
One small change we need to make is to rename all references to
confirm
to password_confirm
to match what our User.changeset
expects.
We’ll also need to refactor how we create the user’s account. Instead of
using Meteor’s Accounts
system, we’ll need to manually make a POST
request to "/api/users"
, passing the user provided email
,
password
, and password_confirm
fields:
fetch("/api/users", {
method: "post",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
user: {
email,
password,
password_confirm
}
})
})
If we receive any errors
from the server, we can drop them directly
into the errors
field of our component’s state:
let errors = json.errors.reduce((errors, error) => {
return Object.assign(errors, error);
});
this.setState({errors});
Otherwise, if everything went well we’ll recieve the newly signed up
user’s JWT and user object in the json.jwt
and json.user
fields.
web/static/js/pages/AuthPage.jsx
+import React from 'react'; +import MobileMenu from '../components/MobileMenu.jsx'; + +// a common layout wrapper for auth pages +const AuthPage = ({ content, link }) => ( + <div className="page auth"> + <nav> + <MobileMenu/> + </nav> + <div className="content-scrollable"> + {content} + {link} + </div> + </div> +); + +AuthPage.propTypes = { + content: React.PropTypes.element, + link: React.PropTypes.element, +}; + +export default AuthPage;
web/static/js/pages/AuthPageJoin.jsx
+import React from 'react'; +import AuthPage from './AuthPage.jsx'; +import { Link } from 'react-router'; + +export default class JoinPage extends React.Component { + constructor(props) { + super(props); + this.state = { errors: {} }; + this.onSubmit = this.onSubmit.bind(this); + } + + onSubmit(event) { + event.preventDefault(); + const email = this.refs.email.value; + const password = this.refs.password.value; + const password_confirm = this.refs.password_confirm.value; + const errors = {}; + + if (!email) { + errors.email = 'Email required'; + } + if (!password) { + errors.password = 'Password required'; + } + if (password_confirm !== password) { + errors.password_confirm = 'Please confirm your password'; + } + + this.setState({ errors }); + if (Object.keys(errors).length) { + return; + } + + fetch("/api/users", { + method: "post", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + user: { + email, + password, + password_confirm + } + }) + }) + .then((res) => { + res + .json() + .then((json) => { + if (json.errors) { + let errors = json.errors.reduce((errors, error) => { + return Object.assign(errors, error); + }); + this.setState({errors}); + } + else { + // TODO: Save `json.user`{:.language-javascript} and `json.jwt`{:.language-javascript} to state + this.context.router.push('/'); + } + }); + }); + } + + render() { + const { errors } = this.state; + const errorMessages = Object.keys(errors).map(key => errors[key]); + const errorClass = key => errors[key] && 'error'; + + const content = ( + <div className="wrapper-auth"> + <h1 className="title-auth">Join.</h1> + <p className="subtitle-auth" >Joining allows you to make private lists</p> + <form onSubmit={this.onSubmit}> + <div className="list-errors"> + {errorMessages.map(msg => ( + <div className="list-item" key={msg}>{msg}</div> + ))} + </div> + <div className={`input-symbol ${errorClass('email')}`{:.language-javascript}}> + <input type="email" name="email" ref="email" placeholder="Your Email"/> + <span className="icon-email" title="Your Email"></span> + </div> + <div className={`input-symbol ${errorClass('password')}`{:.language-javascript}}> + <input type="password" name="password" ref="password" placeholder="Password"/> + <span className="icon-lock" title="Password"></span> + </div> + <div className={`input-symbol ${errorClass('password_confirm')}`{:.language-javascript}}> + <input type="password" name="password_confirm" ref="password_confirm" placeholder="Confirm Password"/> + <span className="icon-lock" title="Confirm Password"></span> + </div> + <button type="submit" className="btn-primary">Join Now</button> + </form> + </div> + ); + + const link = <Link to="/signin" className="link-auth-alt">Have an account? Sign in</Link>; + + return <AuthPage content={content} link={link}/>; + } +} + +JoinPage.contextTypes = { + router: React.PropTypes.object, +};
web/static/js/routes.jsx
... import AppContainer from './containers/AppContainer.jsx'; +import AuthPageJoin from './pages/AuthPageJoin.jsx'; import NotFoundPage from './pages/NotFoundPage.jsx'; ... <Route path="/" component={AppContainer}> + <Route path="join" component={AuthPageJoin}/> <Route path="*" component={NotFoundPage}/>
Enter Redux
Now that we’re receiving the newly signed up user and JWT from the server, we’ll need some way of updating our client-side application state.
Previously, we were doing this reactively with Meteor’s
createContainer
function. Since we’re not using Meteor, this is no longer an option.
Instead, we’ll switch to Redux for all of our state management needs.
The first thing we need to do to get started with Redux is to install our NPM dependencies:
npm install --save redux react-redux
Now we need to think about the “shape” of our application’s state.
Thankfully, we don’t have to think too hard. The shape has been
decided for us in the old AppContainer
component. We’ll define this
“state shape” in a new file, /web/static/js/reducers.js
:
const initialState = {
user: undefined,
jwt: undefined,
loading: false,
connected: true,
menuOpen: false,
lists: []
};
Notice that we slipped in a new field: jwt
. We’ll use this field to
hold the JWT we get as a response from our server when signing in or
signing up.
Now we need to define our reducer:
export default (state = initialState, action) => {
switch(action.type) {
default:
return state;
}
}
Since we don’t have any actions defined yet, our reducer is as simple as it gets.
Now that we have our reducer defined, we can create our
store in app.js
:
const store = createStore(reducers);
We’ll pass our store into our renderRoutes
function so we can wrap our
router in a <Provider>
component:
<Provider store={store}>
<Router ...>
...
</Router>
</Provider>
Lastly, we’ll use subscribe
to trigger a re-render any time our
store
changes.
Now that our Redux store is all wired up, we can pull the state
initialization out of our AppContainer
component and connect it to our
Redux store:
const AppContainer = connect(state => state)(App);
Now our application state is passed from our store, into our
AppContainer
, and down into the App
component.
package.json
"react-dom": "^15.3.1", + "react-redux": "^4.4.5", "react-router": "^2.7.0", + "redux": "^3.6.0", "uglify-js-brunch": ">= 1.0 < 1.8"
web/static/js/app.js
-import { render } from "react-dom"; +import ReactDOM from "react-dom"; +import reducers from "./reducers"; +import { createStore } from "redux"; import { renderRoutes } from "./routes.jsx"; -render(renderRoutes(), document.getElementById("app")); +const store = createStore(reducers); +const el = document.getElementById("app"); + +function render() { + ReactDOM.render(renderRoutes(store), el); +} + +render(); +store.subscribe(render);
web/static/js/containers/AppContainer.jsx
import App from '../layouts/App.jsx'; -import React from 'react'; +import { connect } from "react-redux"; -export default class AppContainer extends React.Component { - constructor(props) { - super(props); - this.state = { - user: undefined, - loading: false, - connected: true, - menuOpen: false, - lists: [] - }; - } +const AppContainer = connect(state => state)(App); - render() { - return (<App {...this.state} {...this.props}/>); - } -}; +export default AppContainer;
web/static/js/reducers/index.js
+const initialState = { + user: undefined, + jwt: undefined, + loading: false, + connected: true, + menuOpen: false, + lists: [] +}; + +export default (state = initialState, action) => { + switch (action.type) { + default: + return state; + } +}
web/static/js/routes.jsx
... import { Router, Route, browserHistory } from 'react-router'; +import { Provider } from "react-redux"; ... -export const renderRoutes = () => ( - <Router history={browserHistory}> - <Route path="/" component={AppContainer}> - <Route path="join" component={AuthPageJoin}/> - <Route path="*" component={NotFoundPage}/> - </Route> - </Router> +export const renderRoutes = (store) => ( + <Provider store={store}> + <Router history={browserHistory}> + <Route path="/" component={AppContainer}> + <Route path="join" component={AuthPageJoin}/> + <Route path="*" component={NotFoundPage}/> + </Route> + </Router> + </Provider> );
Sign Up Actions
Now that we’re using Redux, we’ll want to create actions that describe
the sign-up process. Because sign-up is an asynchronous process, we’ll need three actions: SIGN_UP_REQUEST
,
SIGN_UP_SUCCESS
, and SIGN_UP_FAILURE
:
export function signUpRequest() {
return { type: SIGN_UP_REQUEST };
}
export function signUpSuccess(user, jwt) {
return { type: SIGN_UP_SUCCESS, user, jwt };
}
export function signUpFailure(errors) {
return { type: SIGN_UP_FAILURE, errors };
}
We’ll also pull the fetch
call that we’re using to hit our
/api/users
endpoint into a helper method called signUp
. signUp
will dispatch a SIGN_UP_REQUEST
action, followed by either a
SIGN_UP_SUCCESS
or SIGN_UP_FAILURE
, depending on the result of our
fetch
.
Because we’re using asynchronous actions, we’ll need to pull in the Redux Thunk middleware:
npm install --save redux-thunk
And then wire it into our store:
const store = createStore(
reducers,
applyMiddleware(thunkMiddleware)
);
Now that our actions are defined, we’ll need to create a matching set of
reducers. The SIGN_UP_REQUEST
reducer does nothing:
case SIGN_UP_REQUEST:
return state;
The SIGN_UP_SUCCESS
reducer stores the returned user
and jwt
object in our application state:
case SIGN_UP_SUCCESS:
return Object.assign({}, state, {
user: action.user,
jwt: action.jwt
});
And the SIGN_UP_FAILURE
reducer stores the returned errors
(you’ll
also notice that we added an errors
field to our initialState
):
case SIGN_UP_FAILURE:
return Object.assign({}, state, {
errors: action.errors
});
Great. Now we can wrap our JoinPage
component in a Redux connect
wrapper and pull in errors
from our application state:
(state) => {
return {
errors: state.errors
}
}
And create a helper that dispatches our signUp
asynchronous action:
(dispatch) => {
return {
signUp: (email, password, password_confirm) => {
return dispatch(signUp(email, password, password_confirm));
}
};
}
Now that JoinPage
is subscribed to changes to our store, we’ll need to
move the logic that transforms our errors
into a usable form from its
constructor
into the render
function, and replace the old fetch
logic with a call to signUp
.
After trying to sign up with these changes, we’ll see a runtime error in
our UserMenu
component. It’s expecting the newly signed-in user’s
email address to be in user.emails[0].address
. Changing this component
to pull the address from user.email
fixes the errors.
The sign-up functionality is complete!
package.json
"redux": "^3.6.0", + "redux-thunk": "^2.1.0", "uglify-js-brunch": ">= 1.0 < 1.8"
web/static/js/actions/index.js
+export const SIGN_UP_REQUEST = "SIGN_UP_REQUEST"; +export const SIGN_UP_SUCCESS = "SIGN_UP_SUCCESS"; +export const SIGN_UP_FAILURE = "SIGN_UP_FAILURE"; + +export function signUpRequest() { + return { type: SIGN_UP_REQUEST }; +} + +export function signUpSuccess(user, jwt) { + return { type: SIGN_UP_SUCCESS, user, jwt }; +} + +export function signUpFailure(errors) { + return { type: SIGN_UP_FAILURE, errors }; +} + +export function signUp(email, password, password_confirm) { + return (dispatch) => { + dispatch(signUpRequest()); + return fetch("/api/users", { + method: "post", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + user: { + email, + password, + password_confirm + } + }) + }) + .then((res) => res.json()) + .then((res) => { + if (res.errors) { + dispatch(signUpFailure(res.errors)); + return false; + } + else { + dispatch(signUpSuccess(res.user, res.jwt)); + return true; + } + }); + } +}
web/static/js/app.js
... import reducers from "./reducers"; -import { createStore } from "redux"; +import { createStore, applyMiddleware } from "redux"; import { renderRoutes } from "./routes.jsx"; +import thunkMiddleware from "redux-thunk"; -const store = createStore(reducers); +const store = createStore( + reducers, + applyMiddleware(thunkMiddleware) +); const el = document.getElementById("app");
web/static/js/components/UserMenu.jsx
... const { user, logout } = this.props; - const email = user.emails[0].address; + const email = user.email; const emailLocalPart = email.substring(0, email.indexOf('@'));
web/static/js/pages/AuthPageJoin.jsx
... import { Link } from 'react-router'; +import { connect } from "react-redux"; +import { signUp } from "../actions"; -export default class JoinPage extends React.Component { +class JoinPage extends React.Component { constructor(props) { super(props); - this.state = { errors: {} }; + this.state = { + signUp: props.signUp + }; this.onSubmit = this.onSubmit.bind(this); ... - fetch("/api/users", { - method: "post", - headers: { - "Accept": "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - user: { - email, - password, - password_confirm + this.state.signUp(email, password, password_confirm) + .then((success) => { + if (success) { + this.context.router.push('/'); } - }) - }) - .then((res) => { - res - .json() - .then((json) => { - if (json.errors) { - let errors = json.errors.reduce((errors, error) => { - return Object.assign(errors, error); - }); - this.setState({errors}); - } - else { - // TODO: Save `json.user`{:.language-javascript} and `json.jwt`{:.language-javascript} to state - this.context.router.push('/'); - } - }); }); ... render() { - const { errors } = this.state; + const errors = (this.props.errors || []).reduce((errors, error) => { + return Object.assign(errors, error); + }, {}); const errorMessages = Object.keys(errors).map(key => errors[key]); ... }; + +export default connect( + (state) => { + return { + errors: state.errors + } + }, + (dispatch) => { + return { + signUp: (email, password, password_confirm) => { + return dispatch(signUp(email, password, password_confirm)); + } + }; + } +)(JoinPage);
web/static/js/reducers/index.js
+import { + SIGN_UP_REQUEST, + SIGN_UP_SUCCESS, + SIGN_UP_FAILURE, +} from "../actions"; + const initialState = { - user: undefined, - jwt: undefined, - loading: false, - connected: true, - menuOpen: false, - lists: [] + user: undefined, + jwt: undefined, + loading: false, + connected: true, + menuOpen: false, + lists: [], + errors: [] }; ... export default (state = initialState, action) => { - switch (action.type) { - default: - return state; - } + switch (action.type) { + case SIGN_UP_REQUEST: + return state; + case SIGN_UP_SUCCESS: + return Object.assign({}, state, { + user: action.user, + jwt: action.jwt + }); + case SIGN_UP_FAILURE: + return Object.assign({}, state, { + errors: action.errors + }); + default: + return state; + } }
Final Thoughts
Transitioning away from the react-meteor-data
package’s createContainer
-based container components to a more generalized stat management system like Redux can be a lot of work.
It’s easy to take for granted how simple Meteor makes things.
However, it’s arguable that transitioning to a more predictable state management system is worth the up-front effort. Spending time designing your application’s state, actions, and reducers will leave you with a much more maintainable and predictable system down the road.
Next week we’ll (finally) finish the authentication portion of this project by wiring up our sign-in and sign-out back-end functionality to our Redux-powered front-end.