nooburtino
nooburtino

Reputation: 25

Redux mutation detected between dispatches

I'm having some trouble with a react redux I'm currently working on. I'm relatively new to Redux so maybe I'm missing a simple concept here but what I'm trying to do is build a deck building app for a card game and I want to be able to save the deck anytime a user adds or removes a card from their deck.

However, anytime I click add or remove I'm receiving the following error message while trying to dispatch an update action.

The error message reads as follows:

Uncaught Error: A state mutation was detected between dispatches, in the path `decks.0.cards.mainboard.0.quantity`. This may cause incorrect behavior.

My container component

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

export class DeckEditorContainer extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
            deck: Object.assign({}, this.props.deck)
        }

        this.addCard = this.addCard.bind(this);
        this.removeCard = this.removeCard.bind(this);
    }

    addCard(board, cardName) {
        let deck = this.state.deck;
        let cards = this.state.deck.cards;

        cards[board].forEach(i => {
             if(i.name === cardName)
                i.quantity += 1;
        });

        const update = Object.assign(deck, cards);

        this.props.deckActions.updateDeck(update).then(deck => {
            console.log(deck);
        })
        .catch(err => {
            console.log(err);
        });
    }

    removeCard(board, cardName) {
        let deck = this.state.deck;
        let cards = this.state.deck.cards;

        cards[board].forEach(i => {
             if(i.name === cardName) {
                 if (i.quantity === 1) {
                     cards[board].splice(cards[board].indexOf(i), 1);
                 }
                 else {
                     i.quantity -= 1;
                 }

             }
        });

        const update = Object.assign(deck, cards);

        this.props.deckActions.updateDeck(update).then(deck => {
            console.log(deck);
        })
        .catch(err => {
            console.log(err);
        });
    }

    render() {
        const deck = Object.assign({}, this.props.deck);

        return (
            <div className="editor-container">
                <DeckMobileDisplay
                    deck={deck}
                    addCard={this.addCard}
                    removeCard={this.removeCard}
                    />
            </div>
        );
    }
}

DeckEditorContainer.PropTypes = {
    deck: PropTypes.object
};

function getDeckById(decks, id) {
    const deck = decks.filter(deck => deck.id == id);

    if (deck.length) return deck[0];
    return null;
}

function mapStateToProps(state, ownProps) {
    const deckId = ownProps.params.id;
    let deck = {
        id: '',
        userId: '',
        cards: []
    }

    if (state.decks.length > 0) {
        deck = getDeckById(state.decks, deckId);
    }

    return {
        deck: deck
    };
}

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

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

Component for DeckMobileDisplay

import React, {PropTypes} from 'react';
import TabContainer from '../common/Tabs/TabContainer';
import Tab from '../common/Tabs/Tab';
import CardSearchContainer from '../CardSearch/CardSearchContainer';
import DeckList from './DeckList.js';

class DeckMobileDisplay extends React.Component {
    render() {
        return (
            <TabContainer>
                <Tab title="DeckList">
                    <DeckList
                        deck={this.props.deck}
                        addCard={this.props.addCard}
                        removeCard={this.props.removeCard}
                    />
                </Tab>
                <Tab title="Search">
                    <CardSearchContainer
                        addCard={this.props.addCard}
                        removeCard={this.props.removeCard}
                        />
                </Tab>
                <Tab title="Stats">
                    <p>stats coming soon...</p>
                </Tab>
            </TabContainer>
        );
    }
}

DeckMobileDisplay.propTypes = {
    deck: PropTypes.object.isRequired,
    addCard: PropTypes.func.isRequired,
    removeCard: PropTypes.func.isRequired
}

export default DeckMobileDisplay;

Related Actions

export function createDeck(deck) {
    return dispatch => {
        dispatch(beginAjaxCall());

        const config = {
            method: 'POST',
            headers: { 'Content-Type' : 'application/json' },
            body : JSON.stringify({deck: deck})
        };

        return fetch(`http://localhost:3000/users/${deck.userId}/decks`, config)
            .then(res => res.json().then(deck => ({deck, res})))
            .then(({res, deck}) => {
                if (res.status >= 200 && res.status < 300) {
                    dispatch(createDeckSuccess(deck.deck));
                }
                else
                    dispatch(createDeckFailure(deck));
            })
            .catch(err => {
                console.log(err);
                dispatch(ajaxCallError(err));
            });
    };
}

export function updateDeck(deck) {
    return dispatch => {
        dispatch(beginAjaxCall());

        const body = JSON.stringify({deck: deck});

        const config = {
            method: 'PUT',
            headers : { 'Content-Type' : 'application/json' },
            body: body
        };

        return fetch(`http://localhost:3000/decks/${deck.id}`, config)
            .then(res => res.json().then(deck => ({deck, res})))
            .then(({res, deck}) => {
                if (res.status >= 200 && res.status < 300) {
                    dispatch(updateDeckSuccess(deck.deck));
                }
                    dispatch(ajaxCallError(err));
            })
            .catch(err => {
                console.log(err);
                dispatch(ajaxCallError(err));
            });
    };
}

export function updateDeckSuccess(deck) {
    return {
        type: types.UPDATE_DECK_SUCCESS,
        deck
    };
}

And my deck Reducer

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

export default function deckReducer(state = initialState.decks, action) {
    switch (action.type) {
        case types.LOAD_USERS_DECKS_SUCCESS:
            return action.decks;

        case types.CREATE_DECK_SUCCESS:
            return [
                ...state,
                Object.assign({}, action.deck)
            ]

        case types.UPDATE_DECK_SUCCESS:
            return [
                ...state.filter(deck => deck.id !== action.deck.id),
                Object.assign({}, action.deck)
            ]

        default:
            return state;
    }
}

If you need to see more of the app the repo is here:

https://github.com/dgravelle/magic-redux

Any kind of help would be appreciated, thanks!

Upvotes: 1

Views: 2643

Answers (1)

Facundo La Rocca
Facundo La Rocca

Reputation: 3866

Your problem is caused because you are modifying component's state manually. One Redux's principle is:

State is read-only

The only way to change the state is to emit an action, an object describing what happened.

This ensures that neither the views nor the network callbacks will ever write directly to the state. Instead, they express an intent to transform the state. Because all changes are centralized and happen one by one in a strict order, there are no subtle race conditions to watch out for. As actions are just plain objects, they can be logged, serialized, stored, and later replayed for debugging or testing purposes.

In the method removeCard you are modifying the state:

removeCard(board, cardName) {
    let deck = this.state.deck;
    //This is just a reference, not a clone
    let cards = this.state.deck.cards;

    cards[board].forEach(i => {
         if(i.name === cardName) {
             if (i.quantity === 1) {
                 //Here you are modifying cards, which is a pointer to this.state.deck.cards
                 cards[board].splice(cards[board].indexOf(i), 1);
             }
             else {
                 //Here you are modifying cards, which is a pointer to this.state.deck.cards
                 i.quantity -= 1;
             }
         }
    });
    //... more stuff
}

One concept you might be missing is that this.state.deck.cards is a reference/pointer to the Array's memory position. You need to clone it if you want to mutate it.

One solution could be to clone the original array instead:

removeCard(board, cardName) {
    let deck = this.state.deck;
    //Here you are cloning the original array, so cards references to a totally different memory position
    let cards = Object.assign({}, this.state.deck.cards);

    cards[board].forEach(i => {
         if(i.name === cardName) {
             if (i.quantity === 1) {
                 cards[board].splice(cards[board].indexOf(i), 1);
             }
             else {
                 i.quantity -= 1;
             }

         }
    });
    //... more stuff
}

Hope it helps you.

Upvotes: 2

Related Questions