Core Concepts
A Redux Box application is a collection of modules. Each module is a self-contained state of your store: its initial state, the mutations that update it, the action creators that trigger those mutations, the side effects (sagas), and the derived selectors.
This page walks through each segment in detail.
The anatomy of a module
import { createModule, createSagas } from 'redux-box';
import { call, put } from 'redux-saga/effects';
const state = {
/* initial state */
};
export const dispatchers = {
/* action creators */
};
const mutations = {
/* action.type → (state, action) => void */
};
const sagas = createSagas({
/* action.type → generator function */
});
export const selectors = {
/* derived state */
};
export default createModule({
state,
dispatchers,
mutations,
sagas,
selectors,
});
You can omit any segment you don't need. A module with only state and mutations is perfectly valid.
State
Just a plain object. It becomes the initial value of this module's slice inside combineReducers:
const state = {
items: [],
isLoading: false,
error: null,
};
When a module is registered as createStore({ posts: postsModule }), it lives at state.posts and starts with the value above.
Mutations
Mutations are reducers — but written as if you were mutating state directly. Redux Box runs each one through Immer's produce, so the underlying store stays immutable.
const mutations = {
'posts/ADD': (state, action) => {
state.items.push(action.post);
},
'posts/CLEAR': state => {
state.items = [];
},
};
A few things to keep in mind:
- The first argument is a draft, not the real state. You can freely assign, push, splice, delete keys, etc.
- If you don't change anything, the original object reference is preserved (great for
===equality checks). - You can also return a new object instead of mutating; Immer handles both styles. Just don't do both in the same mutation.
- Async work does not belong here. Use sagas for that.
Action types are strings of your choosing
There's no createTypes() helper. Use whatever string convention you like — 'posts/ADD', 'ADD_POST', Symbol.for('posts/add'). The same string is the key in mutations and the type returned by your dispatcher.
Dispatchers
dispatchers is the module's collection of action creators. Each function returns a plain action object. They're the public, callable surface of the module — components import them (or the whole dispatchers object) to trigger behavior.
export const dispatchers = {
addPost: post => ({ type: 'posts/ADD', post }),
clear: () => ({ type: 'posts/CLEAR' }),
};
When passed to connectStore via mapDispatchers, react-redux automatically wraps them in dispatch():
connectStore({
mapDispatchers: { addPost: dispatchers.addPost },
})(MyComponent);
Inside MyComponent, this.props.addPost(post) will dispatch the action.
Sagas
Sagas are how you handle side effects — API calls, timers, navigation, anything asynchronous. Redux Box uses redux-saga under the hood; if you've written a saga before, this will look familiar.
createSagas converts a { actionType: workerSaga } map into an array of watcher sagas, which createStore then runs as part of the root saga.
import { createSagas } from 'redux-box';
import { call, put } from 'redux-saga/effects';
const sagas = createSagas({
'posts/FETCH_REQUEST': function* () {
try {
const posts = yield call(api.fetchPosts);
yield put({ type: 'posts/FETCH_SUCCESS', posts });
} catch (error) {
yield put({ type: 'posts/FETCH_FAILURE', error: error.message });
}
},
});
By default each watcher uses takeLatest. To switch a single saga to takeEvery, append the __@every suffix to the action key:
const sagas = createSagas({
// takeLatest (default) — only the most recent run survives.
'posts/FETCH_REQUEST': fetchPosts,
// takeEvery — every dispatched action spawns its own run.
'analytics/TRACK__@every': trackEvent,
});
When to use which
takeLatest: idempotent reads (search, fetch, refresh) where you only care about the most recent result.takeEvery: writes / events you don't want to drop (analytics, optimistic posts, queueing).
If you need full control (debounce, throttle, custom watcher), declare the watcher saga manually and add it via the sagas config of createStore — see the Recipes page.
Selectors
Selectors are functions that read from the store. Co-locating them with the module keeps consumers from having to know the module's exact key in state.
import { createSelector } from 'reselect';
export const getItems = state => state.posts.items;
export const getCount = state => state.posts.items.length;
export const getPublishedItems = createSelector(getItems, items =>
items.filter(p => p.published),
);
Eager selectors — mapSelectors
Eager selectors are evaluated on every store update; their result is passed to the component as a regular prop. Use them for values you render directly:
connectStore({
mapSelectors: {
posts: getItems,
publishedPosts: getPublishedItems,
postCount: getCount,
},
})(PostsList);
Inside the component: props.postCount, props.publishedPosts, etc.
Parameterized selectors — mapLazySelectors
Some reads need an argument — "give me the post with id X", "compute total for currency Y". Eager selectors can't easily express that because they're called once per render with (state, ownProps) only.
For these cases, write a normal selector that takes extra arguments and pass it via mapLazySelectors:
export const getPostById = (state, id) =>
state.posts.items.find(p => p.id === id);
connectStore({
mapLazySelectors: { getPostById },
})(PostDetail);
Inside the component the selector arrives as a callable:
function PostDetail({ getPostById, postId }) {
const post = getPostById(postId);
return <article>{post.title}</article>;
}
A few things worth knowing:
The callable's reference is stable across renders — it won't churn
React.memochildren oruseEffectdependencies.Because the reference is stable, a lazy selector by itself does not cause the connected component to re-render when state changes. If you need the component to re-render on those updates, also expose an eager selector for whatever data it depends on. See Parameterized (lazy) selectors in Recipes for the full pattern.
If you'd rather not hard-code
state.postsinside the selector, usemodule.lazySelect(cb)— the mirror ofmodule.select(cb)for parameterized reads. The callback receives the module's slice plus your extra arguments:// store/posts/selectors.js import postsModule from './index'; export const getPostById = postsModule.lazySelect( (slice, id) => slice.items.find(p => p.id === id), );The returned selector has the
(state, ...args) => resultshape thatmapLazySelectorsexpects, and renaming the slice increateStore({ ... })won't break it. Likeselect, it's slice-keyed memoized: same(slice, ...args)⇒ same result by reference, so unrelated dispatches that don't touch this module's slice are cache hits. See Memoization (when you usemodule.lazySelect) for the memory-shape caveat around primitive args.
Decoupling selectors from the slice key
If you'd rather not hard-code state.posts.items, the module exposes module.getSelector() which returns a selector for its own slice:
import postsModule from './posts';
const getPostsSlice = postsModule.getSelector();
export const getItems = state => getPostsSlice(state).items;
This means renaming the slice in createStore({ ... }) won't break selectors.
Connecting to React
connectStore is a thin wrapper around react-redux's connect. It accepts:
connectStore({
mapState, // (state, ownProps) => stateProps
mapSelectors, // { propName: (state, ownProps) => value } — eager
mapLazySelectors, // { propName: (state, ...args) => value } — exposed
// to the component as (...args) => value with a stable ref
mapDispatchers, // { propName: actionCreator } — auto-wrapped in dispatch
mergeProps, // (stateProps, dispatchProps, ownProps) => finalProps
options, // forwarded to react-redux's connect
})(Component);
Use it as a function or as a decorator (if your toolchain supports it):
// Function form (works everywhere)
export default connectStore({ /* ... */ })(MyComponent);
// Decorator form (requires @babel/plugin-proposal-decorators)
@connectStore({ /* ... */ })
class MyComponent extends React.Component { /* ... */ }
About decorators
Decorators are still a stage-2 proposal. To use the @connectStore syntax you'll need a Babel config that includes ['@babel/plugin-proposal-decorators', { legacy: true }]. The function form has no such requirement and is recommended for most projects.
Putting it all together
// src/store/posts.js
import { createModule, createSagas } from 'redux-box';
import { call, put } from 'redux-saga/effects';
import { createSelector } from 'reselect';
import * as api from '../api/posts';
const state = {
items: [],
isLoading: false,
error: null,
};
export const dispatchers = {
fetchPosts: () => ({ type: 'posts/FETCH_REQUEST' }),
clear: () => ({ type: 'posts/CLEAR' }),
};
const mutations = {
'posts/FETCH_REQUEST': state => {
state.isLoading = true;
state.error = null;
},
'posts/FETCH_SUCCESS': (state, action) => {
state.isLoading = false;
state.items = action.posts;
},
'posts/FETCH_FAILURE': (state, action) => {
state.isLoading = false;
state.error = action.error;
},
'posts/CLEAR': state => {
state.items = [];
},
};
const sagas = createSagas({
'posts/FETCH_REQUEST': function* () {
try {
const posts = yield call(api.fetchPosts);
yield put({ type: 'posts/FETCH_SUCCESS', posts });
} catch (error) {
yield put({ type: 'posts/FETCH_FAILURE', error: error.message });
}
},
});
export const getItems = state => state.posts.items;
export const getIsLoading = state => state.posts.isLoading;
export const getError = state => state.posts.error;
export const getItemCount = createSelector(getItems, items => items.length);
export default createModule({ state, dispatchers, mutations, sagas });
That's a complete feature — state, behavior, side effects, and reads — in a single ~40-line file.
Continue with the Simple Example for a runnable counter app, or jump to Recipes for common patterns.