The mapStateToProps / mapDispatchToProps Cheatsheet I Wish I Had In 2018
Every React + Redux codebase from before hooks revolves around connect. Three accordions of the wiring I keep paged in for inheriting one of those repos.
By @milozhang
May 2, 2026
·
Updated May 20, 2026
722 views
8
4.2 (9)
// connect(mapStateToProps, mapDispatchToProps)(Component) is the canonical
// React-Redux idiom. mapState selects the slice the component cares about,
// mapDispatch builds bound action creators. Both run on every store update;
// connect only re-renders when the produced props shallow-differ.
// Tiny store stand-in. Real Redux is essentially this plus middleware.
function createStore(reducer, initialState) {
let state = initialState;
const listeners = [];
return {
getState: () => state,
dispatch: (action) => {
state = reducer(state, action);
listeners.forEach((l) => l());
return action;
},
subscribe: (l) => { listeners.push(l); return () => { const i = listeners.indexOf(l); if (i >= 0) listeners.splice(i, 1); }; },
};
}
function shallowEqual(a, b) {
if (a === b) return true;
if (a == null || b == null) return false;
const ak = Object.keys(a);
if (ak.length !== Object.keys(b).length) return false;
for (const k of ak) if (!Object.is(a[k], b[k])) return false;
return true;
}
function connect(mapState, mapDispatch) {
return function (Component) {
return function Connected(ownProps, store) {
const state = store.getState();
const stateProps = mapState ? mapState(state, ownProps) : {};
const dispatchProps = mapDispatch
? (typeof mapDispatch === 'function' ? mapDispatch(store.dispatch, ownProps) : mapDispatch)
: { dispatch: store.dispatch };
const merged = Object.assign({}, ownProps, stateProps, dispatchProps);
return Component(merged);
};
};
}
// A tiny user reducer + a connected view.
function userReducer(state, action) {
state = state || { name: 'Ada', online: false };
if (action.type === 'SET_ONLINE') return Object.assign({}, state, { online: action.online });
return state;
}
const store = createStore(userReducer, { name: 'Ada', online: false });
function UserBadge(props) {
return props.name + ' is ' + (props.online ? 'online' : 'offline') + ' (toggle: ' + typeof props.toggle + ')';
}
const mapState = (state) => ({ name: state.name, online: state.online });
const mapDispatch = (dispatch) => ({
toggle: (online) => dispatch({ type: 'SET_ONLINE', online }),
});
const ConnectedBadge = connect(mapState, mapDispatch)(UserBadge);
console.log(ConnectedBadge({}, store));
store.dispatch({ type: 'SET_ONLINE', online: true });
console.log(ConnectedBadge({}, store));
// Sanity: the dispatchProps function gives us bound action creators, so the
// view never imports the store or the action types directly.
const dispatchProps = mapDispatch(store.dispatch);
console.log('keys exposed to the view:', Object.keys(dispatchProps).join(', '));The mental model that finally made connect click for me: think of the connected component as a function of (ownProps + storeSlice + boundActionCreators) -> reactTree. mapStateToProps projects the store down to the slice this view cares about, mapDispatchToProps builds action creators that already know how to call dispatch, and the merged object is what the component sees. The view is a pure function of those merged props, exactly like a hook-based component is a pure function of useSelector plus closed-over dispatchers. The optimisation that connect adds is a shallowEqual check on the produced state-props: the wrapped component only re-renders when one of the projected fields actually changed, which is the entire performance story for large connected apps.
// The common shorthand. If you pass an object of action creators as the
// second argument, react-redux internally wraps each one in dispatch and
// gives them to the component as props. Less ceremony, but you lose the
// ability to access the store inside the dispatcher (no thunks for that
// connect call), and you lose ownProps in the dispatch closure.
function createStore(reducer, initialState) {
let state = initialState;
return {
getState: () => state,
dispatch: (action) => { if (typeof action === 'function') return action(this && this.dispatch); state = reducer(state, action); return action; },
};
}
function shallowEqual(a, b) { if (a === b) return true; if (!a || !b) return false; const ak = Object.keys(a); if (ak.length !== Object.keys(b).length) return false; for (const k of ak) if (!Object.is(a[k], b[k])) return false; return true; }
function connect(mapState, mapDispatch) {
return function (Component) {
return function Connected(ownProps, store) {
const stateProps = mapState ? mapState(store.getState(), ownProps) : {};
let dispatchProps;
if (!mapDispatch) {
dispatchProps = { dispatch: store.dispatch };
} else if (typeof mapDispatch === 'function') {
dispatchProps = mapDispatch(store.dispatch, ownProps);
} else {
// Object form: wrap each creator so calling it dispatches the result.
dispatchProps = {};
for (const key of Object.keys(mapDispatch)) {
const creator = mapDispatch[key];
dispatchProps[key] = (...args) => store.dispatch(creator(...args));
}
}
return Component(Object.assign({}, ownProps, stateProps, dispatchProps));
};
};
}
// Action creators (plain functions returning action objects).
const fetchUsers = () => ({ type: 'FETCH_USERS' });
const deleteUser = (id) => ({ type: 'DELETE_USER', payload: { id } });
const userSettings = (settings) => ({ type: 'UPDATE_SETTINGS', payload: settings });
// Reducer that just records the last action for inspection.
let lastAction = null;
function reducer(state, action) { lastAction = action; return state || {}; }
const store = createStore(reducer, {});
// Connected view. View only sees `{ fetchUsers, deleteUser, userSettings }`.
function Users(props) { return Object.keys(props).filter((k) => typeof props[k] === 'function').sort().join(', '); }
const ConnectedUsers = connect(
null,
{ fetchUsers, deleteUser, userSettings }, // shorthand: object of action creators
)(Users);
console.log('view received bound creators:', ConnectedUsers({}, store));
// Calling each is now a one-liner for the view.
const props = {
fetchUsers: (...a) => store.dispatch(fetchUsers(...a)),
deleteUser: (...a) => store.dispatch(deleteUser(...a)),
userSettings: (...a) => store.dispatch(userSettings(...a)),
};
props.fetchUsers();
console.log('after fetchUsers ->', lastAction);
props.deleteUser(42);
console.log('after deleteUser(42) ->', lastAction);
props.userSettings({ theme: 'dark' });
console.log('after userSettings({theme:dark}) ->', lastAction);
// Trade-off: the shorthand cannot access ownProps or getState. If you need
// either, fall back to the function form: (dispatch, ownProps) => ({...}).
console.log('trade-off: lose ownProps closure and thunk access in this form');The shorthand is what I write nine times out of ten. connect(mapState, { fetchUsers, deleteUser, userSettings })(Users) is roughly a third the line count of the function form, and the resulting props (fetchUsers(), deleteUser(id), userSettings(opts)) read like the actions they describe instead of like dispatch indirection. The cost is that you cannot access ownProps or getState() inside the dispatcher; if you need to read a route param to build the action, you have to drop back to (dispatch, ownProps) => ({ ... }). The other gotcha is that thunks (functions returned from action creators) still work in the shorthand only because the bound dispatcher passes the function through to store.dispatch, which redux-thunk recognises; without that middleware the thunk would just become a noop action.
// HOCs compose. The canonical pre-hooks shape for a top-of-the-tree component
// that needs both router context (history, match, location) and store state
// is `compose(withRouter, connect(mapState, mapDispatch))(View)`. Order
// matters: the innermost HOC sees the props the outer ones inject, so router
// props arrive first and connect can read them as ownProps.
// compose: right-to-left function composition. Same as Redux's compose util.
function compose() {
const fns = Array.prototype.slice.call(arguments);
if (fns.length === 0) return (x) => x;
if (fns.length === 1) return fns[0];
return fns.reduce((a, b) => function () { return a(b.apply(null, arguments)); });
}
// withRouter stand-in. Real one pulls router context; we inject a fake one.
function withRouter(Wrapped) {
return function WithRouter(ownProps) {
const router = withRouter.__router || {
history: { push: () => {} },
match: { params: {} },
location: { pathname: '/' },
};
return Wrapped(Object.assign({}, ownProps, router));
};
}
function createStore(reducer, initial) {
let state = initial;
return {
getState: () => state,
dispatch: (a) => { state = reducer(state, a); return a; },
};
}
function connect(mapState, mapDispatch) {
return function (Component) {
return function Connected(ownProps) {
const store = connect.__store;
// mapState reads the store state plus the OUTER ownProps, which now
// include the router props from withRouter.
const stateProps = mapState ? mapState(store.getState(), ownProps) : {};
const dispatchProps = mapDispatch
? (typeof mapDispatch === 'function' ? mapDispatch(store.dispatch, ownProps) : mapDispatch)
: { dispatch: store.dispatch };
return Component(Object.assign({}, ownProps, stateProps, dispatchProps));
};
};
}
// Reducer with a per-route counter. mapState reads route params to look up
// the slice. This is the entire reason we want router-before-connect.
function counterReducer(state, action) {
state = state || { byRoute: { home: 1, settings: 7 } };
return state;
}
connect.__store = createStore(counterReducer, { byRoute: { home: 1, settings: 7 } });
// Inject a router that puts us on /settings.
withRouter.__router = {
history: { push: () => {} },
match: { params: { route: 'settings' } },
location: { pathname: '/settings' },
};
function RouteCounter(props) {
const route = props.match && props.match.params ? props.match.params.route : '(no route)';
const hasHistory = props.history && typeof props.history.push === 'function';
return 'route=' + route + ' | count=' + props.count + ' | lookedUp=' + props.lookedUp + ' | history?=' + hasHistory;
}
const mapState = (state, ownProps) => {
// Defensive read: if the wrapping order is wrong, ownProps.match is
// undefined here. We log the lookup key so the demo at the bottom can
// show the bug rather than throw.
const route = ownProps && ownProps.match && ownProps.match.params && ownProps.match.params.route;
return { count: state.byRoute[route] || 0, lookedUp: String(route) };
};
// compose order: withRouter wraps connect, which wraps the view. Reading
// outside-in: first inject router, then read store using the routed ownProps,
// then render. Reading the function call: connect runs first on the view, then
// withRouter wraps the result.
const enhance = compose(withRouter, connect(mapState));
const Enhanced = enhance(RouteCounter);
console.log(Enhanced({}));
// Switch route to /home, mapState picks up the different slice.
withRouter.__router.match.params.route = 'home';
withRouter.__router.location.pathname = '/home';
console.log(Enhanced({}));
// Quick sanity: the order matters. With compose(connect, withRouter) the
// connect wrapper runs first against ownProps that do NOT yet contain
// match/history, so mapState reads state.byRoute[undefined] and lookedUp
// shows 'undefined'. The router props arrive only inside the inner view.
const BadOrder = compose(connect(mapState), withRouter)(RouteCounter);
console.log('wrong order ->', BadOrder({}));The order I always have to look up: compose(withRouter, connect(...)) is what you want, not the other way around. The outermost HOC runs first at construction time, but the innermost HOC sees the most enriched props at render time, which is what makes router-aware mapStateToProps possible. Concretely: withRouter injects match, history, location onto ownProps; connect then runs mapStateToProps(state, ownProps) and can read ownProps.match.params.route to look up the right store slice. The wrong-order example at the bottom is the bug I have shipped before: a per-route counter that always reads state.byRoute[undefined] and the team spends an afternoon wondering why the dashboard shows zero. compose is the same right-to-left composition Redux exports as a utility; it is just (f, g, h)(x) => f(g(h(x))).
