Introduction
Redux Box is a thin, opinionated container around Redux and Redux-Saga that organizes your application state as a collection of independent modules.
It's still plain Redux underneath — your devtools, middleware, and the rest of the ecosystem all keep working — but you write far less boilerplate to get there.
Why Redux Box?
| Without Redux Box | With Redux Box |
|---|---|
| Action types, action creators, and reducers in separate files. | One module file per feature. |
Manual immutable updates with spread/Object.assign. | Mutate the draft state — Immer makes it immutable. |
Wire up redux-saga middleware and root saga by hand. | Sagas declared in the module are wired for you. |
Boilerplate combineReducers calls. | createStore({ moduleA, moduleB }). |
Installation
npm install redux-box
yarn add redux-box
pnpm add redux-box
Redux Box has the following peer dependencies, install them if your project doesn't already have them:
npm install react react-redux redux redux-saga immer reselect
Your first module
A module is a plain object with up to five segments — state, mutations, dispatchers, sagas, and selectors. You only need to declare the segments you actually use.
Here's a minimal counter:
// src/store/counter.js
import { createModule } from 'redux-box';
const state = { count: 0 };
export const dispatchers = {
increment: () => ({ type: 'counter/INCREMENT' }),
incrementBy: amount => ({ type: 'counter/INCREMENT_BY', amount }),
};
const mutations = {
'counter/INCREMENT': state => {
state.count += 1;
},
'counter/INCREMENT_BY': (state, action) => {
state.count += action.amount;
},
};
export default createModule({ state, mutations });
Things to note:
- The reducer body looks mutable (
state.count += 1). Under the hood, Redux Box runs each mutation through Immer'sproduce, so you get true immutability without the...spreadgymnastics. dispatchersis just a bag of action creators — anything that returns{ type, ...payload }.- The action
typestrings are entirely up to you. Prefixing them with the module name (counter/...) is a useful convention but not required.
Wiring up the store
// src/store/index.js
import { createStore } from 'redux-box';
import counter from './counter';
export default createStore({
counter,
});
The key you give a module here (counter) becomes its slice of the global state — i.e. state.counter.count.
Plugging it into React
Wrap your tree in react-redux's Provider:
// src/index.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './store';
import App from './App';
createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>,
);
Then connect a component using Redux Box's connectStore:
// src/App.js
import React from 'react';
import { connectStore } from 'redux-box';
import { dispatchers } from './store/counter';
function App({ count, increment }) {
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>+1</button>
</div>
);
}
export default connectStore({
mapState: state => ({ count: state.counter.count }),
mapDispatchers: { increment: dispatchers.increment },
})(App);
That's the entire flow:
- Component dispatches
increment(). - The action
counter/INCREMENTreaches thecountermodule's reducer. - The matching mutation runs against an Immer draft of the slice.
- React-Redux re-renders with the new
count.
Where to next?
- Core Concepts — a deeper look at modules, Immer semantics, dispatchers, sagas, and selectors.
- Simple Example — the full counter app, end to end.
- Async Data Fetching — request/success/failure flows with sagas.
- Recipes — devtools, preloaded state, custom reducers, middleware, and more.
- Testing — strategies for testing modules.