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;
@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);
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.
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
}
})
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With