Redux Packet is an alternative interface to react-redux
that helps you hide the internal complexity of action creators and selectors from your components and encapsulates that logic in topic-based files.
Packet helps you to think in terms of Users
, Projects
, Groups
, OtherBusinessObjects
instead of separating out selectors
, action creators
, and reducers
.
Don't write this in your components:
import { connect } from 'react-redux';
import usersForGroupSelector from './selectors';
import loadUsersForGroup from './action-creators';
const mapStateToProps = (state, props) => {
return {
users: usersForGroupSelector(state, props.groupId)
};
};
const mapDispatchToProps = (dispatch, props) => {
return {
loadUsers: () => loadUsersForGroup(props.groupId)
};
};
export default connect(mapStateToProps, mapDispatchToProps)(UserList);
Write this instead:
import { consume } from 'redux-packet';
import users from './user-packet';
export default consume(users.forGroup(props => props.groupId))(UserList);
Redux is great for ensuring your data is decoupled from the data-handling code (e.g. action creators, reducers, selectors) so you can have time-travel debugging, easy hot reloading, etc. But it's less great at decoupling that data-handling code from widget display code. Tightly coupling your UI components to Redux means that changing your state requires changing your components.
It's a good idea to keep the implementation details of Redux (e.g. the fact that all your state is stored in one state
object, the fact that you interact with that state by using a special dispatch
function and action objects that require a certain shape) separate from the ideal interface of Redux (I need this particular state, and give me functions to modify it). Redux Packet does just that.
If you already have react-redux
and redux
in your project, run
npm install --save redux-packet
Otherwise,
npm install --save redux react-redux redux-packet
Create packets of state that can be used across your app:
// import packAll where you deal with Redux state, to create a nice consumable packet of state and actions
import { packAll } from 'redux-packet';
const users = packAll({
// Each property you pass to packAll becomes a packet-making function.
// The packet-making function takes in prop selectors as arguments.
// E.g. this can be used like `user.forGroup(props => props.groupId)`
forGroup: {
// The `selector` property is analogous to mapStateToProps, but it takes in the results of the
// prop selectors instead of props themselves
// It should return the props to inject into a component
selector: (state, groupId) => {
return {
users: state.groups[groupId].users,
//... anything else someone might want for working with users
};
},
// The `actions` property is analogous to mapDispatchToProps, but it takes in the results of
// the prop selectors instead of props themselves
// If you don't need any context like groupId, `actions` can be a plain object and each function
// property will be composed with `dispatch`,
// similar to if you had passed an object to mapDispatchToProps.
actions: (dispatch, groupId) => {
return {
loadUsers: () => dispatch({
type: 'loadUsersForGroup',
groupId: groupId
})
};
};
};
});
Then consume them in React components:
// import consume where you create a Redux-connected React.Component
import { consume } from 'redux-packet';
// a stateless component that might want some user info
const UserList = ({ users, loadUsers }) => (
<ul>{users.map(user => <li {...user} />)}</ul>
<button onClick={loadUsers}>Load</button>
);
// grab the packets you want for your current context and pass them to consume() to
// create a higher-order component.
const withUsersForGroup = consume(users.forGroup(props => props.groupId));
const GroupUserList = withUsersForGroup(UserList);
ReactDOM.render(
<Provider store={createStore(...)}>
<GroupUserList groupId={7} />
</Provider>)
Given a packetDescription
returns a PacketMaker
function that can be called in React components that consume()
them.
{ selector?: Selector|()=>Selector, actions?: Actions|{}, minimumSelectorsExpected?: number}
An object that describes what state and action props are provided in the packet. It must have at least one of a selector
property and an actions
property. It can also have a minimumSelectorsExpected
to enforce that consumers pass in a certain number of context selectors.
(state, ...contextProps) => stateProps
The selector
property takes in the Redux state and the results from any context selectors. For example, in users.forGroup(props => props.groupId)
the selector would receive the value of the groupId
prop from the component.
(dispatch, ...contextProps) => dispatchProps
The actions
property takes in the Redux dispatch and the results from any context selectors. For example, in users.forGroup(props => props.groupId)
the actions would receive the value of the groupId
prop from the component.
Alternatively, you can pass in an object of functions. Each function property will be composed with dispatch for you.
(...contextSelectors) => Packet
Functions returned from pack()
and packAll()
that take in context selectors and output consume()
-able packs.
{ mapStateToProps, mapDispatchToProps, minimumSelectorsExpected?: number }
The shape that a PacketMaker
outputs. You could put the mapStateToProps
and mapDispatchToProps
into connect()
directly if you wanted to.
But it's easier to treat these as opaque shapes you pass to consume()
Given an object where each property is a packetDescription
, return a new object with the same property names whose values are the generated Packet
s.
{ [key: string]: PacketDescription }
An object where each property is a packetDescription
.
consume(packets[, mapPacketsToProps[, mergeProps[, connectOptions]]]) : Component => ConnectedComponent
Array<Packet>|Packet
(...Array<{}>) => {}
Takes in the stash and dispatch props from each packet as a separate argument and combines all properties into one object. For example,
it might be called like mapPacketsToProps({ users, loadUsers }, { projects, loadProjects })
. By default each arguments will be combined into
a single object with Object.assign
.
(Array<{}>, Array<{}>) => {}
Combine the packed properties with any properties passed in from the parent. Be default this will use Object.assign({}, ownProps, allPacketProps)
.
ConnectOptions
Options which will be passed directly to react-redux connect()
Redux states are generally normalized so that details about an entity can be shared and updated as new data comes in. That means when thinking about users, groups, projects, and the relationships between them, you might have state like this:
{
"users": {
"userA": {"some":"details"},
"userB": {"some":"details"}
},
"groups": {
"groupA": {"some":"details"},
"groupB": {"some":"details"}
},
"projects": {
"projectA": {"some":"details"},
"projectB": {"some":"details"}
},
"usersByGroup": {
"groupA": {
"isLoading": false,
"hasAllLoaded": false,
"nextPage": 2,
"errors": [],
"users": ["userA", "userB"]
},
"groupB": {
"isLoading": true,
"hasAllLoaded": false,
"nextPage": 2,
"errors": [],
"users": []
}
},
"usersByProject": {
"projectA": {
"isLoading": false,
"hasAllLoaded": false,
"nextPage": 2,
"errors": [],
"users": ["userB", "userC"]
},
"projectB": {
"isLoading": true,
"hasAllLoaded": false,
"nextPage": 2,
"errors": [],
"users": []
}
},
}
But when working with this state in your code, you typically don't want to deal with all that normalized state and understanding how it fits together. Ideally you'd handle that all in one place and expose it in a more intuitive shape. This is where Redux Packet comes in. It encourages you to create that "one place".
pack()
is how you create that place. pack
lets you define selectors and action creators that take in any context from the caller, and output the appropriate props and actionCreators. Below you'll see a users.forGroup(props => props.groupId)
packet and a users.forProject(props => props.projectId)
packet. Each has a selector
and an actions
property that deliver tailored data about users.
// state/users.js
import { pack } from 'redux-packet';
export default {
forGroup: pack({
selector: (state, groupId) => {
// ...combine all the various bits of state...
const usersForGroup = state.usersByGroup[groupId];
return {
...usersForGroup,
users: usersForGroup.users.map(userId => state.users[userId]),
};
// returns {
// users,
// isLoading,
// hasAllLoaded,
// errors
// }
},
actions: (dispatch, groupId) => ({
loadUsers: () => dispatch({ type: 'LOAD_USERS', groupId })
})
}),
forProject: pack({
selector: (state, projectId, filterTerm) => {
// ...combine all the various bits of state...
const usersForProject = state.usersByProject[projectId];
return {
users: users.filter(user => user.name.indexOf(filterTerm) !== -1,
isLoading: usersForProject.isLoading,
hasAllLoaded: usersForProject.hasAllLoaded,
errors: usersForProject.errors
};
},
actions: (dispatch, projectId, filterTerm) => ({
loadUsers: () => dispatch({ type: 'LOAD_USERS', projectId })
})
})
};
To simplify writing multiple packets, you can use packAll
which expects each property on an object to be a packetDescription.
import { packAll } from 'redux-packet';
export default packAll({
forGroup: {
selector: ...,
actions: ...
},
forProject: {
selector: ...,
actions: ...
}
});
consume()
is how you consume a packet. You call it with your packets, passing in any required context parameters. It's a higher-order component that will call connect()
under the hood and provide your component with the packet properties.
const UserList = (users = [], isLoading, hasAllLoaded, errors, loadUsers) => (
<div>
{errors && <ul>{errors.map(...)}</ul>}
{isLoading && <Spinner />}
<ul>{users.map(...)}</ul>
{hasAllLoaded || <button onClick={loadUsers}>Load users</button>}
</div>
);
import { consume } from 'redux-packet';
import users from '../state/users';
import UserList from './user-list';
const ProjectUserList = consume(users.forProject(props => props.projectId))(UserList);
const GroupUserList = consume(users.forGroup(props => props.groupId))(UserList);
// <ProjectUserList projectId="projectA" />
// <GroupUserList groupId="groupA" />
You can call consume()
with multiple packets, but if any properties overlap, you'll have to combine them yourself with a mapPacketsToProps
:
const CompareUserLists = ({ listA, listB }) => (
<div>
<UserList {...listA} />
<UserList {...listB} />
</div>
);
const CompareUsersInGroups = consume(
[
users.forGroup(props => props.groupA),
users.forGroup(props => props.groupB)
],
(listA, listB) => ({
listA,
listB
})
)(CompareUserLists);
// <CompareUsersInGroups groupA="groupA" groupB="groupB" />