A Duck that creates Ducks O_O
I love Erik Rasmussen’s Ducks proposal for bundling your reducers and action types/creators. Thinking of reducers/actions as a package has been valuable to me, as they’re often consumed as such. This package is called a Duck.
Let’s code a Duck!
We’ll be making a higher-order “list” Duck, one that allows you to perform several operations on a list of items. The items can be anything.
Our Duck must have a way to:
- Add one/many items
- Remove an item
- Update an item
- Set the list
- Empty the list (reset)
As a higher-order Duck, it’s meant to be used to create new Ducks that can maintain lists of anything. See Redux documentation on higher-order reducers if you’re unfamiliar.
Getting Started
You won’t need anything fancy to follow along. Action creators and reducers are just functions, after all. I’ll be using a general ES6 + Webpack setup, which you’ll find everywhere.
Pick your favorite and come back here.
Action Types
What’s an action without a type, huh? We’ve defined our Duck’s requirements so writing the action types are an easy first step. They’ll help us decide what to name our action creators.
const actionTypes = {
reset: 'RESET',
addOne: 'ADD_ONE',
addMany: 'ADD_MANY',
removeOne: 'REMOVE_ONE',
updateOne: 'UPDATE_ONE',
set: 'SET'
};
This’d be fine if we weren’t writing a higher-order Duck, but since it’s meant to create many list Ducks, they can’t share action types. Imagine firing a RESET
action and resetting every list reducer in your application!
“Named types” to the rescue!
As described in the Redux docs, named types are a pattern for making truly reusable higher-order reducers. Let’s refactor our action types to get a clearer picture.
const makeListDuck = (name) => {
const actionTypes = {
reset: `${name}RESET`,
addOne: `${name}ADD_ONE`,
addMany: `${name}ADD_MANY`,
removeOne: `${name}REMOVE_ONE`,
updateOne: `${name}UPDATE_ONE`,
set: `${name}SET`
};
return {
actionTypes
};
};
Now each Duck’s guaranteed unique action types. Invoking our current higher-order Duck looks like this:
And guess what? We’re doing the same thing for our action creators and reducer.
Action Creators
Let’s do the set
and reset
action creators first.
const actionCreators = {
reset: () => ({
type: actionTypes.reset
}),
set: (items) => ({
type: actionTypes.set,
items
})
};
Easy-peasy. Our action types already have parameterized names we can use. Don’t forget to include everything in the return
statement.
makeListDuck = (name) => {
// ...stuff
return {
actionTypes,
actionCreators
};
};
Invoking our Duck now looks like this
Now we’ll write addOne
and addMany
.
const actionCreators = {
reset: () => ({
type: actionTypes.reset
}),
addOne: (item) => ({
type: actionTypes.addOne,
item
}),
addMany: (items) => ({
type: actionTypes.addMany,
items
}),
set: (items) => ({
type: actionTypes.set,
items
})
};
Lastly removeOne
and updateOne
. We need a way to identify what to update/remove. I originally wanted to put a predicate function in the action object, so our action creators would have looked like this.
removeOne: (predicate) => ({
type: actionTypes.removeOne,
predicate
}),
updateOne: (predicate, newItem) => ({
type: actionTypes.updateOne,
predicate,
newItem
})
But That Ain’t Serializable
Serializable means your actions are easily converted into a format (like a string
) and stored in memory or disk for later use. You then “replay” those stored actions to rehydrate the store (after a server render for example) or use time-travel debugging.
As Luca Matteis kindly commented, functions may leave your actions unserializable. Redux docs speaks on this issue here.
If you are okay with things like persistence and time-travel debugging potentially not working as intended, then you are totally welcome to put non-serializable items into your Redux store.
Using a predicate is still enticing because it helps guarantee that we’re updating the correct item. So instead of putting predicates in the actions, let’s define them as helper functions.
findItemById = (id) => (item) => item.id === id;
findItemById
takes an id
and returns a new function that we’ll plug directly into array methods like .map
, .find
, and .filter
. It’ll return the first matching item.id
. This’ll be our reducer’s secret weapon.
Moving our logic into the reducer simplifies things drastically, as the actions can just broadcast what happened and trust the reducer to respond appropriately.
Back to the Action (Creators)!
removeOne: (oldItem) => ({
type: actionTypes.removeOne,
oldItem
}),
updateOne: (oldItem, newItem) => ({
type: actionTypes.updateOne,
oldItem,
newItem
}),
Both action creators need the oldItem
, and updateOne
also needs the newItem
as the replacement.
Reducer
Here’s the set
and reset
code
const reducer = (state = [], action) => {
switch (action.type) {
case actionTypes.reset: {
return [];
}
case actionTypes.set: {
return action.items;
}
}
};
Pretty straightforward.
Next, addOne
and addMany
switch-cases.
case actionTypes.addOne: {
return [...state, action.item];
}
case actionTypes.addMany: {
return [...state, ...action.items];
}
I’m using spread syntax to merge the existing and new item(s). Check out my article on spread if it’s new to you.
Here’s removeOne
.
case actionTypes.removeOne: {
const { oldItem } = action;
return state.filter((item) => (
!findItemById(oldItem.id)(item)
));
}
findItemById
makes its first appearance! We pass it to state.filter
and negate the result, so we keep whatever doesn’t match oldItem
.
Now updateOne
.
case actionTypes.updateOne: {
const { oldItem, newItem } = action;
return state.map((item) => (
findItemById(oldItem.id)(item) ? newItem : item
));
}
Similar to removeOne
, we’re using findItemById
. If it returns true
, replace oldItem
with newItem
, otherwise return item
.
Here’s what it looks like altogether.
Don’t forget to return everything in your Duck!
makeListDuck = (name) => {
// ...stuff
return {
actionTypes,
actionCreators,
reducer
};
};
Let’s Try It Out!
Initializing it returns an empty array.
Passing state and no action type returns the state.
We can add an item
We can add many items
We can remove items
We can replace mangos with kiwis.
We can set the list, regardless of the previous state.
We can reset the state.
And we can do it all thousands of times because it’s a higher-order Duck!