Skip to content

Commit bea186f

Browse files
combineActions: handle multiple actions with the same reducer (#113)
close #31
1 parent 6589c31 commit bea186f

File tree

8 files changed

+236
-10
lines changed

8 files changed

+236
-10
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,29 @@ const reducer = handleActions({
180180
}, { counter: 0 });
181181
```
182182

183+
### `combineActions(...actionTypes)`
184+
185+
Combine any number of action types or action creators. `actionTypes` is a list of positional arguments which can be action type strings, symbols, or action creators.
186+
187+
This allows you to reduce multiple distinct actions with the same reducer.
188+
189+
```js
190+
const { increment, decrement } = createActions({
191+
INCREMENT: amount => ({ amount }),
192+
DECREMENT: amount => ({ amount: -amount }),
193+
})
194+
195+
const reducer = handleAction(combineActions(increment, decrement), {
196+
next: (state, { payload: { amount } }) => ({ ...state, counter: state.counter + amount }),
197+
throw: state => ({ ...state, counter: 0 }),
198+
}, { counter: 10 })
199+
200+
expect(reducer(undefined, increment(1)).to.deep.equal({ counter: 11 })
201+
expect(reducer(undefined, decrement(1)).to.deep.equal({ counter: 9 })
202+
expect(reducer(undefined, increment(new Error)).to.deep.equal({ counter: 0 })
203+
expect(reducer(undefined, decrement(new Error)).to.deep.equal({ counter: 0 })
204+
```
205+
183206
## Usage with middleware
184207
185208
redux-actions is handy all by itself, however, its real power comes when you combine it with middleware.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { combineActions, createActions } from '../';
2+
import { expect } from 'chai';
3+
4+
describe('combineActions', () => {
5+
it('should throw an error if any action is not a function or string', () => {
6+
expect(() => combineActions(1, 'ACTION_2'))
7+
.to.throw(TypeError, 'Expected action types to be strings, symbols, or action creators');
8+
9+
expect(() => combineActions('ACTION_1', () => {}, null))
10+
.to.throw(TypeError, 'Expected action types to be strings, symbols, or action creators');
11+
});
12+
13+
it('should accept action creators and action type strings', () => {
14+
const { action1, action2 } = createActions('ACTION_1', 'ACTION_2');
15+
16+
expect(() => combineActions('ACTION_1', 'ACTION_2'))
17+
.not.to.throw(Error);
18+
expect(() => combineActions(action1, action2))
19+
.not.to.throw(Error);
20+
expect(() => combineActions(action1, action2, 'ACTION_3'))
21+
.not.to.throw(Error);
22+
});
23+
24+
it('should return a stringifiable object', () => {
25+
const { action1, action2 } = createActions('ACTION_1', 'ACTION_2');
26+
27+
expect(combineActions('ACTION_1', 'ACTION_2')).to.respondTo('toString');
28+
expect(combineActions(action1, action2)).to.respondTo('toString');
29+
expect(combineActions(action1, action2, 'ACTION_3')).to.respondTo('toString');
30+
});
31+
});

src/__tests__/handleAction-test.js

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from 'chai';
2-
import { handleAction, createAction, createActions } from '../';
2+
import { handleAction, createAction, createActions, combineActions } from '../';
33

44
describe('handleAction()', () => {
55
const type = 'TYPE';
@@ -108,4 +108,91 @@ describe('handleAction()', () => {
108108
});
109109
});
110110
});
111+
112+
describe('with combined actions', () => {
113+
it('should handle combined actions in reducer form', () => {
114+
const action1 = createAction('ACTION_1');
115+
const reducer = handleAction(
116+
combineActions(action1, 'ACTION_2', 'ACTION_3'),
117+
(state, { payload }) => ({ ...state, number: state.number + payload })
118+
);
119+
120+
expect(reducer({ number: 1 }, action1(1))).to.deep.equal({ number: 2 });
121+
expect(reducer({ number: 1 }, { type: 'ACTION_2', payload: 2 })).to.deep.equal({ number: 3 });
122+
expect(reducer({ number: 1 }, { type: 'ACTION_3', payload: 3 })).to.deep.equal({ number: 4 });
123+
});
124+
125+
it('should handle combined actions in next/throw form', () => {
126+
const action1 = createAction('ACTION_1');
127+
const reducer = handleAction(combineActions(action1, 'ACTION_2', 'ACTION_3'), {
128+
next(state, { payload }) {
129+
return { ...state, number: state.number + payload };
130+
}
131+
});
132+
133+
expect(reducer({ number: 1 }, action1(1))).to.deep.equal({ number: 2 });
134+
expect(reducer({ number: 1 }, { type: 'ACTION_2', payload: 2 })).to.deep.equal({ number: 3 });
135+
expect(reducer({ number: 1 }, { type: 'ACTION_3', payload: 3 })).to.deep.equal({ number: 4 });
136+
});
137+
138+
it('should handle combined error actions', () => {
139+
const action1 = createAction('ACTION_1');
140+
const reducer = handleAction(combineActions(action1, 'ACTION_2', 'ACTION_3'), {
141+
next(state, { payload }) {
142+
return { ...state, number: state.number + payload };
143+
},
144+
145+
throw(state) {
146+
return { ...state, threw: true };
147+
}
148+
});
149+
const error = new Error;
150+
151+
expect(reducer({ number: 0 }, action1(error)))
152+
.to.deep.equal({ number: 0, threw: true });
153+
expect(reducer({ number: 0 }, { type: 'ACTION_2', payload: error, error: true }))
154+
.to.deep.equal({ number: 0, threw: true });
155+
expect(reducer({ number: 0 }, { type: 'ACTION_3', payload: error, error: true }))
156+
.to.deep.equal({ number: 0, threw: true });
157+
});
158+
159+
it('should return previous state if action is not one of the combined actions', () => {
160+
const reducer = handleAction(
161+
combineActions('ACTION_1', 'ACTION_2'),
162+
(state, { payload }) => ({ ...state, state: state.number + payload }),
163+
);
164+
165+
const state = { number: 0 };
166+
167+
expect(reducer(state, { type: 'ACTION_3', payload: 1 })).to.equal(state);
168+
});
169+
170+
it('should use the default state if the initial state is undefined', () => {
171+
const reducer = handleAction(
172+
combineActions('INCREMENT', 'DECREMENT'),
173+
(state, { payload }) => ({ ...state, counter: state.counter + payload }),
174+
{ counter: 10 }
175+
);
176+
177+
expect(reducer(undefined, { type: 'INCREMENT', payload: +1 })).to.deep.equal({ counter: 11 });
178+
expect(reducer(undefined, { type: 'DECREMENT', payload: -1 })).to.deep.equal({ counter: 9 });
179+
});
180+
181+
it('should handle combined actions with symbols', () => {
182+
const action1 = createAction('ACTION_1');
183+
const action2 = Symbol('ACTION_2');
184+
const action3 = createAction(Symbol('ACTION_3'));
185+
const reducer = handleAction(
186+
combineActions(action1, action2, action3),
187+
(state, { payload }) => ({ ...state, number: state.number + payload })
188+
);
189+
190+
expect(reducer({ number: 0 }, action1(1)))
191+
.to.deep.equal({ number: 1 });
192+
expect(reducer({ number: 0 }, { type: action2, payload: 2 }))
193+
.to.deep.equal({ number: 2 });
194+
expect(reducer({ number: 0 }, { type: Symbol('ACTION_3'), payload: 3 }))
195+
.to.deep.equal({ number: 3 });
196+
});
197+
});
111198
});

src/__tests__/handleActions-test.js

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from 'chai';
2-
import { handleActions, createAction, createActions } from '../';
2+
import { handleActions, createAction, createActions, combineActions } from '../';
33

44
describe('handleActions', () => {
55
it('create a single handler from a map of multiple action handlers', () => {
@@ -69,6 +69,62 @@ describe('handleActions', () => {
6969
});
7070
});
7171

72+
it('should accept combined actions as action types in single reducer form', () => {
73+
const { increment, decrement } = createActions({
74+
INCREMENT: amount => ({ amount }),
75+
DECREMENT: amount => ({ amount: -amount })
76+
});
77+
78+
const initialState = { counter: 10 };
79+
80+
const reducer = handleActions({
81+
[combineActions(increment, decrement)](state, { payload: { amount } }) {
82+
return { ...state, counter: state.counter + amount };
83+
}
84+
}, initialState);
85+
86+
expect(reducer(initialState, increment(5))).to.deep.equal({ counter: 15 });
87+
expect(reducer(initialState, decrement(5))).to.deep.equal({ counter: 5 });
88+
expect(reducer(initialState, { type: 'NOT_TYPE', payload: 1000 })).to.equal(initialState);
89+
expect(reducer(undefined, increment(5))).to.deep.equal({ counter: 15 });
90+
});
91+
92+
it('should accept combined actions as action types in the next/throw form', () => {
93+
const { increment, decrement } = createActions({
94+
INCREMENT: amount => ({ amount }),
95+
DECREMENT: amount => ({ amount: -amount })
96+
});
97+
98+
const initialState = { counter: 10 };
99+
100+
const reducer = handleActions({
101+
[combineActions(increment, decrement)]: {
102+
next(state, { payload: { amount } }) {
103+
return { ...state, counter: state.counter + amount };
104+
},
105+
106+
throw(state) {
107+
return { ...state, counter: 0 };
108+
}
109+
}
110+
}, initialState);
111+
const error = new Error;
112+
113+
// non-errors
114+
expect(reducer(initialState, increment(5))).to.deep.equal({ counter: 15 });
115+
expect(reducer(initialState, decrement(5))).to.deep.equal({ counter: 5 });
116+
expect(reducer(initialState, { type: 'NOT_TYPE', payload: 1000 })).to.equal(initialState);
117+
expect(reducer(undefined, increment(5))).to.deep.equal({ counter: 15 });
118+
119+
// errors
120+
expect(
121+
reducer(initialState, { type: 'INCREMENT', payload: error, error: true })
122+
).to.deep.equal({ counter: 0 });
123+
expect(
124+
reducer(initialState, decrement(error))
125+
).to.deep.equal({ counter: 0 });
126+
});
127+
72128
it('should work with createActions action creators', () => {
73129
const { increment, decrement } = createActions('INCREMENT', 'DECREMENT');
74130

src/combineActions.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import isString from 'lodash/isString';
2+
import isFunction from 'lodash/isFunction';
3+
import isEmpty from 'lodash/isEmpty';
4+
import toString from 'lodash/toString';
5+
import isSymbol from 'lodash/isSymbol';
6+
7+
export const ACTION_TYPE_DELIMITER = '||';
8+
9+
function isValidActionType(actionType) {
10+
return isString(actionType) || isFunction(actionType) || isSymbol(actionType);
11+
}
12+
13+
function isValidActionTypes(actionTypes) {
14+
if (isEmpty(actionTypes)) {
15+
return false;
16+
}
17+
return actionTypes.every(isValidActionType);
18+
}
19+
20+
export default function combineActions(...actionsTypes) {
21+
if (!isValidActionTypes(actionsTypes)) {
22+
throw new TypeError('Expected action types to be strings, symbols, or action creators');
23+
}
24+
25+
const combinedActionType = actionsTypes.map(toString).join(ACTION_TYPE_DELIMITER);
26+
27+
return { toString: () => combinedActionType };
28+
}

src/createAction.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default function createAction(type, payloadCreator, metaCreator) {
2929
return action;
3030
};
3131

32-
actionHandler.toString = () => type;
32+
actionHandler.toString = () => type.toString();
3333

3434
return actionHandler;
3535
}

src/handleAction.js

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
import isFunction from 'lodash/isFunction';
22
import identity from 'lodash/identity';
33
import isNil from 'lodash/isNil';
4-
import isSymbol from 'lodash/isSymbol';
4+
import includes from 'lodash/includes';
5+
import { ACTION_TYPE_DELIMITER } from './combineActions';
56

6-
export default function handleAction(type, reducers, defaultState) {
7-
const typeValue = isSymbol(type)
8-
? type
9-
: type.toString();
7+
export default function handleAction(actionType, reducers, defaultState) {
8+
const actionTypes = actionType.toString().split(ACTION_TYPE_DELIMITER);
109

1110
const [nextReducer, throwReducer] = isFunction(reducers)
1211
? [reducers, reducers]
1312
: [reducers.next, reducers.throw].map(reducer => (isNil(reducer) ? identity : reducer));
1413

1514
return (state = defaultState, action) => {
16-
if (action.type !== typeValue) {
15+
if (!includes(actionTypes, action.type.toString())) {
1716
return state;
1817
}
1918

src/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import createAction from './createAction';
22
import handleAction from './handleAction';
33
import handleActions from './handleActions';
4+
import combineActions from './combineActions';
45
import createActions from './createActions';
56

67
export {
78
createAction,
89
createActions,
910
handleAction,
10-
handleActions
11+
handleActions,
12+
combineActions
1113
};

0 commit comments

Comments
 (0)