Validating Actions using Acceptors in our State Machine
Objective
To ensure the validity of our state by rejecting actions that would lead to an invalid state thru the use of acceptors.
Setup
We have a made up card game where the cards are either in a players hand or a pile. The only action that exist in our card game is play-card
and our application state is as follows
type CardCollection = Array<Card>;
interface GameState {
pile: CardCollection;
players: Array<CardCollection>;
};
const exampleState: GameState = {
pile: ['A♤', '2♡', '7♧'],
players: [
['4♢', '5♢', 'J♤'],
['8♡', '6♢', 'Q♧']
]
};
Problem
Problem 1
const handReducer = (hand: Array<Card> = [], action) => {
switch (action.type) {
case 'play-card': {
return hand.filter(currentCard => currentCard !== action.card);
}
}
return hand;
}
Our handReducer receives an action of play-card
. If the card is found, we remove it from the players hand, but if the player does not have that card, this reducer essentially does nothing. There is no harm here but it could prove useful to let our application know whether an action produced a new state or not.
Problem 2
const pileReducer = (pile: Array<Card> = [], action) => {
switch (action.type) {
case 'play-card': {
return [...pile, action.card];
}
}
return pile;
}
Here when our action 'play-card' is dispatched we add the card to the pile. Looking at our exampleState above, if we dispatch an action of {type: 'play-card', card: '6♧'}
none of our players have that card but somehow a it magically appears out of thin air on our pile.
The problem here is that the pile reducer only knows about its state, likewise for the hand reducer. The reducers by themselves do not have enough insight on whether the action should be accepted by the system as a whole.
Solution
An Acceptor can help remedy the problems above. An acceptor is just like a reducer but instead of returning a piece of state back it just returns true|false depending on whether the action was accepted or not.
const handAcceptor = (hand: Array<Card> = [], action) => {
switch (action.type) {
case 'play-card': {
return hand.includes(action.card);
}
}
return true;
}
const pileAcceptor = (pile: Array<Card> = [], action) => {
switch (action.type) {
case 'play-card': {
return !pile.includes(action.card);
}
}
return true;
}
Here we see that the handAcceptor returns true if the card is contained in the hand, and on the other side of the spectrum pile returns true if the card isn't present. This ensures that the card only exist in one place, and will reject an action that leads to an invalid state.
For the complete code please look at the following link.
Before we consume an action, we want to run it by our acceptors first. If any of our acceptors reject the action, we don't bother sending it to our reducers
const getState = (state: GameState, action: any) => {
if (mainAcceptor(state, action)) {
const newState = mainReducer(state, action);
console.log('NewState = ', newState);
return newState;
}
console.log('The action', action, 'was not accepted. State remains unchanged');
return state;
};
This solves for our problem # 2. All acceptors have to be okay with an action in order for it to hit our reducer.
As for problem # 1, we could expand on our acceptors to return an error stating exactly why an action wasn't accepted. While I did not code up this example it should be relatively straight forward to implement.
// if acceptors returned an error
const getState = (state: GameState, action: any) => {
const acceptorResults = mainAcceptor(state, action);
if (acceptorResults.accepted) {
const newState = mainReducer(state, action);
console.log('NewState = ', newState);
return newState;
}
console.log('Error=', acceptorResults.error);
return state;
};
Conclusion
By incorporating acceptors into our code we can ensure that every action consumed by our reducers leads to a valid state without increase the complexity of our reducers while simultaneously keeping our coding pattern consistent with the rest of the code.