Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React and Redux: Uncaught Error: A state mutation was detected between dispatches

Tags:

reactjs

redux

I am using 'controlled' components (using setState() within the component) and getting this error intermittently when attempting to save the form data. The UserForm onSave calls back to the saveUser method in the component code below.

I've looked at the Redux docs on this and can't quite get my head around where I'm modifying the state to cause the error in the title, which is specifically: Uncaught Error: A state mutation was detected between dispatches, in the path 'users.2'. This may cause incorrect behavior.

As far as I can tell, only local modifications are being made, and the reducer is returning a copy of the global state with my changes added. I must be missing something, but what?

Here's the component code:

import React, {PropTypes} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import * as userActions from '../../actions/userActions';
import UserForm from './UserForm';


export class ManageUserPage extends React.Component {
  constructor(props, context) {
    super(props, context);

    this.state = {
      user:  Object.assign({}, this.props.user),
      errors:  {},
      saving:  false
    };
    this.updateUserState = this.updateUserState.bind(this);
    this.saveUser = this.saveUser.bind(this);
  }

  componentWillReceiveProps(nextProps) {
    if (this.props.user.id != nextProps.user.id) {
      this.setState(
        {
          user:  Object.assign({}, nextProps.user)
        }
      );
    }
  }

  updateUserState(event) {
    const field = event.target.name;
    let user = Object.assign({}, this.state.user);
    user[field] = event.target.value;
    return this.setState({user: user});
  }

  userFormIsValid() {
    let formIsValid = true;
    let errors = {};

    if (this.state.user.firstName.length < 3) {
      errors.firstName = 'Name must be at least 3 characters.';
      formIsValid = false;
    }
    this.setState({errors: errors});
    return formIsValid;
  }


  saveUser(event) {
    event.preventDefault();
    if (!this.userFormIsValid()) {
      return;
    }
    this.setState({saving: true});
    this.props.actions
      .saveUser(this.state.user)
      .then(() => this.redirect())
      .catch((error) => {
        this.setState({saving: false});
      });
  }

  redirect() {
    this.setState({saving: false});
    this.context.router.push('/users');
  }

  render() {
    return (
      <UserForm
        onChange={this.updateUserState}
        onSave={this.saveUser}
        errors={this.state.errors}
        user={this.state.user}
        saving={this.state.saving}/>
    );
  }
}

ManageUserPage.propTypes = {
  user:  PropTypes.object.isRequired,
  actions: PropTypes.object.isRequired
};

ManageUserPage.contextTypes = {
  router: PropTypes.object
};


function getUserById(users, userId) {
  const user = users.find(u => u.id === userId);
  return user || null;
}

function mapStateToProps(state, ownProps) {
  let user = {
    id:        '',
    firstName: '',
    lastName:  ''
  };

  const userId = ownProps.params.id;

  if (state.users.length && userId) {
    user = getUserById(state.users, userId);
  }


  return {
    user:  user
  };
}

function mapDispatchToProps(dispatch) {
  return {
    actions: bindActionCreators(userActions, dispatch)
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(ManageUserPage);

Here's the reducer:

import * as types from '../actions/actionTypes';
import initialState from './initialState';


export default(state = initialState.users, action) =>
{
  switch (action.type) {
    case types.CREATE_USER_SUCCESS:
      return [
        // grab our state, then add our new user in
        ...state,
        Object.assign({}, action.user)
      ];

    case types.UPDATE_USER_SUCCESS:
      return [
        // filter out THIS user from our copy of the state, then add our updated user in
        ...state.filter(user => user.id !== action.user.id),
        Object.assign({}, action.user)
      ];

    default:
      return state;
  }
};

Here are the actions:

import * as types from './actionTypes';
import userApi from '../api/mockUserApi';
import {beginAjaxCall, ajaxCallError} from './ajaxStatusActions';


export function createUserSuccess(user) {
  return {type: types.CREATE_USER_SUCCESS, user};
}

export function updateUserSuccess(user) {
  return {type: types.UPDATE_USER_SUCCESS, user};
}

export function saveUser(user) {
  return function (dispatch, getState) {
    dispatch(beginAjaxCall());
    return userApi.saveUser(user)
      .then(savedUser => {
        user.id
          ? dispatch(updateUserSuccess(savedUser))
          : dispatch(createUserSuccess(savedUser));
      }).catch(error => {
        dispatch(ajaxCallError(error));
        throw(error);
      });
  };
}

Here's the mock API layer:

import delay from './delay';

const users = [
  {
    id: 'john-smith',
    firstName: 'John',
    lastName: 'Smith'
  }
];

const generateId = (user) => {
  return user.firstName.toLowerCase() + '-' + user.lastName.toLowerCase();
};

class UserApi {
  static saveUser(user) {
    user = Object.assign({}, user); 
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        const minUserNameLength = 3;
        if (user.firstName.length < minUserNameLength) {
          reject(`First Name must be at least ${minUserNameLength} characters.`);
        }

        if (user.lastName.length < minUserNameLength) {
          reject(`Last Name must be at least ${minUserNameLength} characters.`);
        }

        if (user.id) {
          const existingUserIndex = users.findIndex(u => u.id == u.id);
          users.splice(existingUserIndex, 1, user);
        } else {
          user.id = generateId(user);
          users.push(user);
        }
        resolve(user);
      }, delay);
    });
  }
}

export default UserApi;
like image 364
Matt Morgan Avatar asked Dec 09 '16 00:12

Matt Morgan


3 Answers

@DDS pointed me in the right direction (thanks!) in that it was mutation elsewhere that was causing the problem.

ManageUserPage is the top-level component in the DOM, but a different component on another route called UsersPage, was mutating state in its render method.

Initially the render method looked like this:

render() {
    const users = this.props.users.sort(alphaSort);
    return (
      <div>
        <h1>Users</h1>
        <input type="submit"
               value="Add User"
               className="btn btn-primary"
               onClick={this.redirectToAddUserPage}/>
        <UserList
          users={users}/>
      </div>
    );
}

I changed the users assignment to the following and the issue was resolved:

const users = [...this.props.users].sort(alphaSort);
like image 109
Matt Morgan Avatar answered Oct 31 '22 00:10

Matt Morgan


The problem isn't in this component or the reducer. It's probably in the parent component, where you're probably assigning users[ix] = savedUser somewhere, and the users array is ultimately the same array as the one in the state.

like image 9
DDS Avatar answered Oct 31 '22 00:10

DDS


Solved the problem with state mutations by following any of the update patterns suggested on redux official docs

I prefer using createReducer in reducer which uses immer.produce behind the scenes:

import { createReducer } from 'redux-starter-kit'

const initialState = {
  first: {
    second: {
      id1: { fourth: 'a' },
      id2: { fourth: 'b' }
    }
  }
}

const reducer = createReducer(initialState, {
  UPDATE_ITEM: (state, action) => {
    state.first.second[action.someId].fourth = action.someValue
  }
})
like image 1
Ilarion Halushka Avatar answered Oct 31 '22 02:10

Ilarion Halushka