deuce2
v0.1.19
Published
A library to make real-world Redux usage easier and to reduce boilerplate.
Downloads
4
Keywords
Readme
Deuce
A library to make real-world Redux usage easier and to reduce boilerplate.
Pain points we hope to address:
Creating selectors that are colocated with the associated reducer is difficult, since the selector needs to know the full path to that part of the state. If you want to change some part of the state tree at a higher level, you then need to update all selectors that depend on that state path.
When you have a reducer that has any sort of loading from the server, you're constantly reimplementing the same loading state tracking and error tracking.
In a single-page app, if you're keeping each page's state in the tree, you need a way to reset a page's state across page navigation. Otherwise, the next time they come back to the page, its old state will persist.
Almost all reducers are composed of one big switch statement, which requires the same repetitive boilerplate for assigning a default value, getting the payload of the action, and switching on action.type. Any boilerplate that can be reduced in Redux is a win.
What's inside Deuce?
combineReducersAndSelectors()
: A replacement forcombineReducers
that also packages in simple selectors. These selectors are wrapped with the path to the specific part of the state, so they can be written in the same file as the reducer and operate on the same small state object.loadable()
: A higher-order reducer that upgrades a simple reducer with loading state and error tracking, for a given async action.resettable()
: A higher-order reducer that upgrades a simple reducer with the ability to reset when a special action is dispatched.createReducer()
: A helper function for building simple reducers that would otherwise be created with aswitch
statement onaction.type
and may use the action'spayload
.
combineReducersAndSelectors()
Problem
Let's say you have a state shape like this:
root
|- score
|- team
And you're working on a simple score
reducer. It would be really nice to have any selector functions live inside your score.js
reducer file, since you're actually defining the shape of the score
state inside that file.
The problem is that your reducer will need to look something like:
const selectScorePart = (state: RootState) => state.path.to.score.scorePart;
Now, if we ever change the path to the score
reducer, we'll have to go to that file and change all of the selector functions to know about the new path to the reducer from root
(and likely change a bunch of other affected reducer files as well).
Solution
Let's say we wanted to make the root
reducer above, using Redux's combineReducers()
function. We'd do something like this:
const rootReducer = combineReducers({
score: scoreReducer,
team: teamReducer,
});
The combineReducersAndSelectors()
function works a lot like combineReducers()
, except that instead of taking a map of reducers, it takes a map of objects of the shape
{
reducer: Reducer,
selectors: {
[selectorName: string]: Selector },
}
}
I'll talk about those selectors in a minute. But let's just recreate what we did with combineReducers
using combineReducersAndSelectors
:
const { reducer as rootReducer } = combineReducersAndSelectors({
score: {
reducer: scoreReducer,
selectors: {}, // none for now
},
team: {
reducer: teamReducer,
selectors: {}, // none for now
}
});
That would make the exact same rootReducer
as before (it actually uses combineReducers
under the hood).
Now, on to these selectors. The selectors are simple functions that take a part of the state (like score
or team
above) and select something from it. The don't need to know anything about where that part of the state is in the root
tree.
const selectScorePart = (state: scoreState) => state['the part you want'];
When you put these selectors in combineReducersAndSelectors()
, it auto-magically provides them with the part of the state they care about, just like combineReducers
passes our reducer the part of the state it cares about.
Here's our combineReducersAndSelectors
setup with some selectors added:
const { reducer as rootReducer } = combineReducersAndSelectors({
score: {
reducer: rootReducer,
selectors: {
selectHomeScore, // These names need to be globally unique from other selectors
selectAwayScore,
}
},
team: {
reducer: teamReducer,
selectors: {
selectHomeTeamName, // Also unique, even from the selectors in `score`
selectAwayTeamName,
}
}
})
It's important that you give your selector a unique name, unique from any other selector in the entire state tree.
Here's where it gets cool. combineReducersAndSelectors()
returns an object with the same shape as its argument. It's an object with a reducer
key and selectors
.
The reducer
key is the result of combineReducers
on all the reducers passed to combineReducersAndSelectors
.
The selectors
are upgraded versions of all the selectors
you passed to combineReducersAndSelectors
that take the global root
state as their input. They are smart enough to take the root
state and pass just the part that your original selector cared about to it.
Here's an example:
// Your original selector
const selectHomeScore = (state: ScoreState) => state.homeScore;
// Your upgraded selector, created automatically with combineReducersAndSelectors().
// It now takes the root state, so you can use it in your container components.
// But, it has the exact same name as the original.
const selectHomeScore = (state: ScoreState) => state.score.homeScore;
Now, you can use these selectors in your connected container components. If you want to change the state tree hierarchy, all you do is change the object you pass to combineReducersAndSelectors
— none of your reducers or selectors need to change!
For example, if you renamed score
to points
in the Redux tree, your selectHomeScore
selector above would automatically now select state.points.homeScore
.
loadable()
Problem
Most reducers are really simple at their core. They're often tracking very few things and making really simple mutations. But, as soon as they start needing to support asynchronous updates with loading states (because users need to see a loading spinner) and error states (because users are human), your reducer balloons in complexity.
Solution
Let's say our score
reducer, from the example in the combineReducersAndSelectors
section, had a state with this shape:
const defaultState = {
homeScore: 1,
awayScore: 2,
}
And, let's say the reducer for this is really simple — it listens for one action type with new scores to apply:
const scoreReducer = (score: ScoreState, action) => {
if (action.type === 'score/SET_SCORES') {
return action.payload; // { homeScore: #, awayScore: # }
}
return score; // For any other action, do nothing
}
Now, let's imagine we want to load these scores from an async call to the server, which is done with a helper function called getScores()
.
We might put it in an action like this, relying on the redux-promise-middleware
to recognize it's an action and dispatch the appropriate _PENDING
, _FULFILLED
, or _REJECTED
actions for us:
const getScoresAction = () => ({
type: 'score/SET_SCORES',
payload: getScores(), // Our async server call that returns a Promise
});
It would be pretty annoying to make a bunch of changes to our dead-simple reducer to listen for all three action types that redux-promise-middleware
dispatches. Then our reducer would be more about loading than the actual scores!
Instead, we can just upgrade it with loadable()
:
const wrappedReducer = loadable(scoreReducer, 'score/SET_SCORES'); // The second arg is the base action to make loadable. Can be a string or an array of multiple strings if you want to make multiple actions loadable.
Now, our new wrappedReducer
has the same shape as our previous scoreReducer
, but with two special keys tracked by loadable
:
loading
: a boolean for whether we are currently waiting on the servererror
: either null, or the payload of the action if our Promise is rejected
So, now our reducer state would have a default value like this:
{
loading: false,
error: null,
homeScore: 1,
awayScore: 2,
}
loadable()
keeps up with the Promise, updating loading
and error
for us. Here's how those keys look acrosss the different dispatches from redux-promise-middleware
:
'[our action key]_PENDING'
:loading
istrue
,error
isnull
'[our action key]_FULFILLED'
:loading
isfalse
,error
isnull
, and an action is given to our reducer matching{ type: '[our action key]', payload: (same payload as the resolved promise) }
'[our action key]_REJECTED'
:loading
isfalse
,error
is thepayload
of the rejected action (from our Promise).
NOTE: the original reducer is not called when there's an error, for two reasons:
- You can check if there's an error now, and render the UI accordingly
- It's often useful to still know the previous state when there's an error. For example, if we had a scoreboard component using our
score
reducer, then we could keep the old scores in place while notifying the user that they weren't able to be updated. That's better than showing nothing (although you could, if you wanted to, by listening for theerror
key).
In our example, keep in mind our original reducer didn't have to change at all. It still listens for score/SET_SCORES
and updates the homeScore
and awayScore
like it used to.
When connecting this new wrapped loadable
reducer with a container component, you can use the provided selectors isLoading
and getError
, which take the state tracked by the reducer as an argument and return the contents of the loading
and error
keys created by loadable
.
Listening for other actions to reset loadable
state
From time to time, you may want to listen for other actions that should reset the loading and error state. For example, if your user was attempting to save the name of an object and you got an error from the server that the name was a duplicate, you might want to clear the error message once they edit the name.
resettable()
Problem
In a single-page app, we often want various states to reset when the user does certain things. For example, we might want to clear the state used by a page when the user navigates away so that it isn't the same if they come back.
Solution
Assuming we had the same state tree as before:
root
|- score
|- team
Let's say we want score
to reset when the user navigates away, but team can stay cached. We could make a custom action for score
that gets dispatched. But that would get tedious in a real app, because we could have dozens of states for different pages that each need the same boilerplate.
To make that easier, you can use the resettable()
higher-order reducer. It will add handling for a special action that, when dispatched, will cause the state to reset (this is done by dispatching an action to your reducer with an undefined
state, so that the default value will be returned).
The special action can be created with the resetStates()
action creator. Just dispatch the action returned by the function, and any reducer that has been wrapped in resettable
will reset itself.
For the use case of resetting states when the user leaves a page, you could have a shared component on all pages dispatch the action in its componentWillUnmount
.
createReducer()
Problem
Redux reducers are tedious. You have to remember to make a default state and to return the state unchanged if there's an action you're not listening for. You have to make a switch
for action.type
, and remember to get the contents of action.payload
, over and over again every time you build a reducer.
Solution
The createReducer()
helper removes all this boilerplate.
It takes a gnarly reducer file like this:
const getDefaultState = ({
score: 1,
});
const reducer(state = getDefaultState(), action) = {
switch(action.type) {
case 'SUM':
return { score: state.score + action.payload };
case 'DIFFERENCE':
return { score: state.score - action.payload };
case 'DOUBLE':
return { score: state.score * 2 };
case 'NEW_SCORE':
return { score: action.payload };
default:
return state;
}
}
and lets you replace it with this svelt file:
const reducer = createReducer(
{ score: 1 }, // default state to use
{
'SUM' /* the action.type */: ({ score }, payload) => { score: score + payload },
'DIFFERENCE': ({ score }, payload) => { score: score - payload },
'DOUBLE': ({ score }) => { score: score * 2 },
'NEW_SCORE': (_, payload) => { score: payload },
},
);
Your skinny new reducer will automatically return its default state when initiated, listen for the appropriate action.type
, and return the state unchanged when there are actions you don't care about.