redunk
v1.0.0
Published
Typed Redux reducers, action creators and hooks generated from a single declaration
Maintainers
Readme
Redunk lets you declare a slice of Redux state once and get everything else for free: the reducer, the action types, the action creators, and two fully typed hooks for reading state and dispatching actions.
No action type constants. No switch statements. No mapStateToProps. No manually typed selectors.
const { reducer, useActions, useReducerState } = createReducer({
name: "counter",
initialState: 0,
reducers: state => ({
increment: state + 1,
decrement: state - 1,
incrementBy: (amount: number) => state + amount,
}),
});That is the whole store slice. useActions() now returns { increment, decrement, incrementBy },
each correctly typed, and useReducerState() returns a number.
Features
- Strong typing — state, action parameters and selectors are inferred by TypeScript. No generics to thread through your app.
- Runtime typing — optional prop-types validation of every state transition, useful for plain JavaScript and untrusted API data.
- Immutable by default — mutate the draft freely inside reducers; Immer produces the next state for you.
- Self-contained reducers — components read and write a slice without knowing its name or where it sits in the store.
- Multi-argument actions — a reducer case takes as many parameters as you like, not a single
payload. - Drop-in compatible — works with plain Redux,
combineReducers,connect(),useSelector/useDispatchand the Redux DevTools.
Installation
npm install redunkyarn add redunkRedunk relies on peer dependencies, so install Redux and React-Redux alongside it:
npm install redux react-redux| Peer dependency | Supported range |
| --------------- | ---------------------- |
| react | ^18.0.0 \|\| ^19.0.0 |
| react-redux | ^9.0.0 |
| redux | ^5.0.0 |
Quick start
A complete counter application, from store to render.
import { createRoot } from "react-dom/client";
import { createStore } from "redux";
import { Provider } from "react-redux";
import { createRootReducer } from "redunk";
// 1. Declare the state and how it changes.
const { reducer, useActions, useReducerState } = createRootReducer({
initialState: 0, // State inferred as `number`
reducers: state => ({
increment: state + 1,
decrement: state - 1,
}),
});
// 2. Read and write it from any component.
function Counter() {
const count = useReducerState();
const { increment, decrement } = useActions();
return (
<div>
<h1>Counter: {count}</h1>
<button onClick={decrement}>-</button>
<button onClick={increment}>+</button>
</div>
);
}
// 3. Wire up the store as usual.
const store = createStore(reducer);
createRoot(document.getElementById("root")!).render(
<Provider store={store}>
<Counter />
</Provider>
);createRootReducer is for apps where a single reducer owns the entire store. Once you have more than
one slice, use createReducer and give each slice a name.
Working with multiple reducers
createReducer namespaces its action types (counter/increment) and scopes useReducerState to its
own slice. It also returns namedReducer — the reducer already wrapped in { [name]: reducer } — so
combining slices is a matter of spreading them.
// store/counter.ts
import { createReducer } from "redunk";
export const { namedReducer, useActions, useReducerState } = createReducer({
name: "counter",
initialState: 0,
reducers: state => ({
increment: state + 1,
decrement: state - 1,
}),
});// store/todos.ts
import { createReducer } from "redunk";
type Todo = { text: string; completed: boolean };
export const { namedReducer, useActions, useReducerState } = createReducer({
name: "todos",
initialState: [] as Todo[],
reducers: state => ({
add: (text: string) => {
state.push({ text, completed: false });
},
toggle: (index: number) => {
state[index].completed = !state[index].completed;
},
remove: (index: number) => {
state.splice(index, 1);
},
}),
});// store/index.ts
import { combineReducers, createStore } from "redux";
import { namedReducer as counter } from "./counter";
import { namedReducer as todos } from "./todos";
export const store = createStore(combineReducers({ ...counter, ...todos }));
// equivalent to combineReducers({ counter: counterReducer, todos: todosReducer })Because the slice name travels with the reducer, a component never has to know where in the store its state lives:
import { useActions, useReducerState } from "./store/todos";
function TodoList() {
const todos = useReducerState();
const { toggle, remove } = useActions();
return (
<ul>
{todos.map((todo, i) => (
<li key={i}>
<span onClick={() => toggle(i)}>
{todo.completed ? <s>{todo.text}</s> : todo.text}
</span>
<button onClick={() => remove(i)}>x</button>
</li>
))}
</ul>
);
}Writing reducers
reducers is a function that receives the current state and returns an object of reducer cases.
Each key becomes an action type and an action creator. A case can be one of three things:
createReducer({
name: "example",
initialState: { count: 0, label: "" },
reducers: state => ({
// 1. A plain value — the next state, dispatched with no arguments.
reset: { count: 0, label: "" },
// 2. A function returning the next state.
setCount: (count: number) => ({ ...state, count }),
// 3. A function that mutates the draft and returns nothing.
// Immer turns this into an immutable update.
increment(by: number, reason?: string) {
state.count += by;
if (reason) state.label = reason;
},
}),
});Cases that return void are handed to Immer, so mutating state
is safe — a new object is produced instead of the original being modified.
Reducer case names become the action types that show up in the DevTools, so give them descriptive
names. Unlike Redux Toolkit's createSlice, a case can declare any number of parameters:
const { useActions } = createReducer({
name: "grid",
initialState: [] as number[][],
reducers: state => ({
setCell: (row: number, column: number, value: number) => {
state[row][column] = value;
},
}),
});
const { setCell } = useActions();
setCell(2, 5, 42); // fully typedHooks
Reducers created with Redunk expose two hooks.
useActions()
Returns an object of dispatch functions named after your reducer cases. It replaces useDispatch and
mapDispatchToProps.
function Buttons() {
const { increment, incrementBy } = useActions();
return (
<>
<button onClick={increment}>+1</button>
<button onClick={() => incrementBy(5)}>+5</button>
</>
);
}useActions().incrementBy(5) is shorthand for
dispatch({ type: "counter/incrementBy", payload: [5] }).
useReducerState(selector?, equalityFn?)
A wrapper around React-Redux's useSelector,
already scoped to this reducer's slice. It replaces useSelector and mapStateToProps.
const wholeSlice = useReducerState();
const name = useReducerState(state => state.data.name);
const items = useReducerState(state => state.items, shallowEqual);The component re-renders only when the selected value changes.
Using the classic Redux APIs
Redunk does not lock you in. The returned actions object holds ordinary action creators, so
connect() and useDispatch work exactly as you would expect.
const { reducer, actions } = createReducer({
name: "counter",
initialState: 0,
reducers: state => ({
increment: state + 1,
incrementBy: (amount: number) => state + amount,
}),
});
actions.incrementBy(5);
// → { type: "counter/incrementBy", payload: [5] }const mapDispatchToProps = dispatch => ({
increment: () => dispatch(actions.increment()),
});
export default connect(state => ({ value: state.counter }), mapDispatchToProps)(Counter);Action types are plain, readable strings, so they appear as-is in the Redux DevTools and can be handled by other reducers or middleware.
Runtime validation with prop-types
Pass proptypes to validate the state produced by initialState and by every action. This is
especially handy in plain JavaScript projects, or when state is built from API responses that
TypeScript cannot verify.
import PropTypes from "prop-types";
const { useActions } = createReducer({
name: "user",
initialState: { id: undefined, name: undefined, cookiesAccepted: false },
proptypes: {
id: PropTypes.string,
name: PropTypes.string,
cookiesAccepted: PropTypes.bool.isRequired,
},
reducers: state => ({
setName: (name: string) => {
state.name = name;
},
}),
});proptypes accepts either an object of validators or a single validator for non-object state:
createRootReducer({
initialState: 0,
proptypes: PropTypes.number.isRequired,
reducers: state => ({ increment: state + 1 }),
});A failing transition logs a warning pointing at the exact action:
Warning: Failed value for type: Invalid value for `name` of type `number`
supplied to `user/setName`, expected `string`.API
createReducer(options)
| Option | Type | Required | Description |
| -------------- | --------------------------------- | -------- | ----------------------------------------------------------------------------------------- |
| name | string | yes | Slice name. Used as the action type prefix (name/case) and as the key in namedReducer. |
| initialState | any | yes | Initial state. Its type is inferred and drives the typing of everything else. |
| reducers | (state) => Record<string, Case> | yes | Returns the reducer cases. Each key generates an action type and an action creator. |
| proptypes | PropTypes validator or object | no | Validates state after initialState and after every action. |
Returns
| Key | Type | Description |
| ----------------- | ---------- | ----------------------------------------------------------------------------------------------------------------- |
| namedReducer | object | { [name]: reducer }. Spread into combineReducers — the recommended way to mount a slice. |
| useActions | function | Hook returning typed dispatch functions for every reducer case. |
| useReducerState | function | Hook returning this slice's state; accepts an optional selector and equality function. |
| actions | object | Plain action creators, e.g. { type: "todos/add", payload: [...] }, for use with connect() and useDispatch. |
| reducer | function | The raw reducer function, for createStore() or a manual combineReducers entry. |
createRootReducer(options)
Identical to createReducer minus the name prefix. Action types are unprefixed and
useReducerState reads the whole store state. Use it when one reducer owns the entire store.
Options: initialState, reducers, proptypes.
Returns: reducer, actions, useActions, useReducerState.
Compared to Redux Toolkit's createSlice
Redunk covers the same ground as createSlice but
adds the read/write hooks and lets cases take multiple arguments.
// Redux Toolkit
const value = useSelector(state => state.counter.value); // untyped without extra setup
const dispatch = useDispatch();
dispatch(incrementByAmount(5));
// Redunk
const value = useReducerState(); // typed, scoped to the slice
const { incrementByAmount } = useActions();
incrementByAmount(5, "because");Since useReducerState knows its own slice, a component can consume a reducer without knowing its
name, its state shape, or where it sits in the store — which makes reducers portable across projects.
TypeScript
Types are inferred end to end from initialState and the reducers function. You rarely need to
annotate anything; when you do, annotate initialState:
type State = { items: string[]; loading: boolean };
createReducer({
name: "list",
initialState: { items: [], loading: false } as State,
reducers: state => ({
/* `state` is State, action parameters are yours to declare */
}),
});Module formats
The package ships both an ESM build (dist/esm) and a CommonJS build (dist/cjs), each with its own
type declarations, selected through the exports map. It is marked sideEffects: false and is safe
to tree-shake.
Documentation
Full documentation, guides and live CodeSandbox examples: ivoiv.github.io/redunk
Contributing
Issues and pull requests are welcome at github.com/ivoiv/redunk.
git clone https://github.com/ivoiv/redunk.git
cd redunk
npm install
npm test
npm run buildLicense
MIT © Ivo Ivanov
