User Profile Page - Part 3


In this video we are going to implement the Redux Reducer for our Profile journey. Before we create the reducer implementation, there is one task to cover with regards to our API - this will only affect those of us using Symfony for our API.

To quickly recap, when a User visits their /profile page, a new action is dispatched to start the process of requesting their profile from our Symfony 3 API.

As part of this process we also tell our application's state that a new request has started. We configure and then send out a request to the API, and await its response. When the response arrives, we either dispatch a new action to indicate everything went well, or that we encountered problems along the way. Whatever the outcome, we dispatch a final action to indicate the current request has finished.

The core of this process is largely identical whether you are request a User's profile, changing their password, retrieving some related data, or pretty much anything else you could want to do in a typical web application. This standardised process is what makes this particular implementation grow in a predictable and reliable fashion. Well, that's my opinion anyway.

Now, when our request actually hits our Symfony API, in the video we encountered a particular issue that we have already covered before: namely that the serialised representation of our User entity is going to contain waaay too much data.

If following along, firstly this issue may not occur for you. We have already addressed it in the linked video. However, if like me, you do encounter the issue whereby the JSON representing your User contains a bunch of unwanted fields - salt, password hash, locked / expired, and so on - then firstly clear your cache from your Symfony project directory.

A note on this though - be sure to clear the cache for the environment you are working in. In my case, I am working in the 'acceptance' environment, so my command would be:

php bin/console cache:clear --env=acceptance

# or

php bin/console cache:clear -e=acceptance

If this doesn't work, follow the guidance layed out in the linked video as the problem here is that as we are extending FOSUserBundle's User object, which by default doesn't contain any guidance for JMSSerializer on which fields to expose, and which to hide. By default every field will be exposed, hence our problem.

Anyway, please do shout up (by way of leaving a comment) if this problem persists beyond these two guidelines.

Implementing Profile Reducer

Whatever platform / language you are using for your backend API, by now your request for the logged in User's profile should have returned a valid response containing - at the very least - the user's email address, and username.

{
  "id": 1,
  "username": "chris",
  "email": "chris@codereviewvideos.com"
}

We can see this more clearly in the code for our profileSaga's doRequestProfile function:

    const responseBody = yield call(api.fetchProfile, userId);

    const {username, email} = responseBody;

    yield put({
      type: types.PROFILE__REQUESTED__SUCCEEDED,
      payload: {
        id: userId,
        username,
        email
      }
    });

Currently when dispatching this PROFILE__REQUESTED__SUCCEEDED action, this information is effectively sent into the abyss. We have not yet configured anything to watch for actions of this particular type.

What we now want to happen is to watch for this new data, use it to update our application's current state, and that in turn will cascade down to any of our component's configured to react to changes to this particular part of state.

If unsure, the benefits of this should become more clear as we step through the code changes.

Let's start by creating a new Reducer:

// /src/reducers/profileReducer.js

export default function profile(state = {}, action) {

  switch (action.type) {

    default:
      return state;
  }
}

And let's ensure we add this new reducer to the configuration object given to combineReducers:

// /src/reducers/index.js

import { combineReducers } from 'redux';
import {routerReducer} from 'react-router-redux';
import {reducer as formReducer} from 'redux-form';
import auth from './authReducer';
import profile from './profileReducer';
import request from './requestReducer';

const rootReducer = combineReducers({
  form: formReducer,
  routing: routerReducer,
  auth,
  profile,
  request,
});

export default rootReducer;

Note that in our rootReducer, we have used the ES6 convention of including just the key for profile. If the key and the value would be identical, we can omit the value. To put it more clearly visible, this rootReducer config would be identical:

// /src/reducers/index.js

const rootReducer = combineReducers({
  form: formReducer,
  routing: routerReducer,
  auth: auth,
  profile: profile,
  request: request,
});

The reason I flag this up is that whatever key we use here will be the key we need to look for in our application's state. This could be different from the name of our reducer, but it makes sense / saves your sanity to keep them the same.

Switching back to the profileReducer, even though this profile function looks fairly standard, there are two important points to cover before going further.

Firstly, we have added a default case to our switch statement. If there are no matching cases, then simply return the existing state.

Secondly, we are using a default argument for our state: state = {}. If state is undefined when this function is called - which it may be when our application is initially loaded - then we set the sensible default of an empty object. Remember this value of state will be returned if no matching action.type is met, so we need to send back something sane.

The job of our Reducer is to return the next state for this particular part of our application. To do this, we must define a pure function that takes the arguments of the existing state and an action, and returns the new state.

A pure function is a function that is free from side effects. We are using Redux Saga to handle all our side effects, so at this point we can be sure we are dealing with unchanging data.

If you are new to this concept, I would strongly recommend you read through the Reducer documentation before continuing.

The profile function signature matches that required by a Redux reducer - it takes the existing state, and an action. Our task is to now create / determine the next state, and return it.

To do this, we will need to create a new matching case statement for the action.type of PROFILE__REQUESTED__SUCCEEDED:

// /src/reducers/profileReducer.js

import {
  PROFILE__REQUESTED__SUCCEEDED
} from '../constants/actionTypes';

export default function profile(state = {}, action) {

  switch (action.type) {

    case PROFILE__REQUESTED__SUCCEEDED: {

      const {id, username, email} = action.payload;

      return Object.assign({}, state, {
        id,
        username,
        email
      });
    }

    default:
      return state;
  }
}

Ok, lots to cover.

We must define a case statement that matches PROFILE__REQUESTED__SUCCEEDED. To begin with, therefore we must import that constant from actionTypes.

Once imported, we can define a case using that constant. Remember, this constant is just a simple string. You could use raw strings here, but by using constants you have centralised the value should you ever wish to change it. Remember, we use this constant also in our profileSaga, and potentially other places too.

We use the ES6 standard destructuring technique to extract the id, username, and email from the action.payload. This is exactly the information we dispatched in our profileSaga earlier:

    yield put({
      type: types.PROFILE__REQUESTED__SUCCEEDED,
      payload: {
        id: userId,
        username,
        email
      }
    });

Finally, we use Object.assign to ensure we have created a brand new object, onto which we first apply whatever is in the state already (or that empty {} object if undefined), and then override any existing values with our new key / value pairs.

To clarify, I have created a further example on JSBin:

Object assign example on JSBin

Code sample if lost for any reason:

let state = {}

const exampleA = Object.assign({}, state, {
  id: 15,
  username: "jim",
  email: "jim@jimmyjohnson.com"
});

// nothing exists in state to begin with
console.log('exampleA', exampleA);

// -----------------------------------------
state = {
  username: "bob",
  email: "bob@bobbington.com"
}

const exampleB = Object.assign({}, state, {
  id: 27,
  email: "bob@jimmyjohnson.com"
});

// some existing values in state, so first apply those values to
// the empty object (first param of .assing), and then override
// with the values in the third argument
console.log('exampleB', exampleB);

The one last thing to do here would be to add in some sensible default values to our state so that when our application is first loaded, the expected keys are set, even if the values are undefined:

// /src/reducers/profileReducer.js

import {
  PROFILE__REQUESTED__SUCCEEDED
} from '../constants/actionTypes';

export default function profile(state = {
  id: undefined,
  username: undefined,
  email: undefined
}, action) {

  switch (action.type) {

    case PROFILE__REQUESTED__SUCCEEDED: {
      const {id, username, email} = action.payload;
      return Object.assign({}, state, {
        id,
        username,
        email
      });
    }

    default:
      return state;
  }
}

With our reducer completed, we can now take full advantage of what this offers us. Namely, making our ProfileArea dynamic.

Map State To Props

Our Reducer is taking the result of our action, our application's existing state, and ultimately returning the next state.

We can now use Redux's mapStateToProps function to watch the Redux Store - the container of our application's state - for any changes to the state, and if seen, we can then take these values out of state and use them as props.

Currently we have the following hardcoded props being passed to ProfileArea:

// /src/containers/ProfilePage.js

class ProfilePage extends React.Component {

  // * snip *

  render() {
    return (
      <div>
        <ProfileArea username="peter" emailAddress="peter@whatever.com"/>
      </div>
    );
  }
}

I've removed a bunch of code here to cut down on noise, but you can see the full code here if needed.

Instead of using these hardcoded values, let's instead pull the values from state:

// /src/containers/ProfilePage.js

import React, {PropTypes} from 'react';
import {connect} from 'react-redux';
import ProfileArea from '../components/ProfileArea';
import * as types from '../constants/actionTypes';

class ProfilePage extends React.Component {

  componentDidMount() {
    this.props.dispatch({
      type: types.PROFILE__REQUESTED,
      payload: {
        userId: this.props.pageState.auth.id
      }
    });
  }

  render() {

    const {
      username = '',
      email = ''
    } = this.props.pageState.profile;

    return (
      <div>
        <ProfileArea username={username} emailAddress={email}/>
      </div>
    );
  }

}

ProfilePage.propTypes = {
  dispatch: PropTypes.func.isRequired,
  pageState: PropTypes.object.isRequired
};

function mapStateToProps(state) {
  return {
    pageState: state
  };
}

export default connect(mapStateToProps)(ProfilePage);

One slight downside in setting username and email to undefined in our Reducer's initial state is that our ProfileArea will throw an error on first load, as it expects these values to be defined. I've simply set both to be an empty string by default here, but maybe this would be better set in the Reducer's initial state instead:

export default function profile(state = {
  id: undefined,
  username: '',
  email: ''
}, action) {

I'm not sure how I feel about this, as an empty string is still something, and that's not a true representation of the initial state. My preference is to therefore override in the ProfilePage's render method where this is more specific. Your opinion may vary, and I'm open to further thought on this.

Ultimately though, at this point we now have a dynamic profile page area, displaying the username and email address of the currently logged in User. We have restricted this route down so that users must be logged in to see this page, so no dangers of accidentally triggering this for users who are not logged in.

Sure, the data we are pulling back is trivial, and it seems like a lot of work to retrieve two fields. But think about this on a larger scale, with many more fields - and hopefully you can see the benefits of this approach.

Code For This Course

Get the code for this course.

Code For This Video

Get the code for this video.

Episodes