Phoenix Todos - Transition to Redux

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.

Written by Pete Corey on Sep 21, 2016.

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.