https://github.com/esamattis/immer-reducer
Type-safe and terse reducers with Typescript for React Hooks and Redux
https://github.com/esamattis/immer-reducer
react redux typescript
Last synced: about 2 months ago
JSON representation
Type-safe and terse reducers with Typescript for React Hooks and Redux
- Host: GitHub
- URL: https://github.com/esamattis/immer-reducer
- Owner: esamattis
- License: mit
- Created: 2018-11-04T20:24:04.000Z (over 6 years ago)
- Default Branch: master
- Last Pushed: 2022-11-18T13:42:40.000Z (over 2 years ago)
- Last Synced: 2025-04-10T01:08:36.109Z (about 2 months ago)
- Topics: react, redux, typescript
- Language: TypeScript
- Homepage: http://npm.im/immer-reducer
- Size: 286 KB
- Stars: 224
- Watchers: 4
- Forks: 15
- Open Issues: 11
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# immer-reducer
Type-safe and terse reducers with Typescript for React Hooks and Redux using [Immer](https://immerjs.github.io/immer/)!
## 📦 Install
npm install immer-reducer
You can also install [eslint-plugin-immer-reducer](https://github.com/skoshy/eslint-plugin-immer-reducer) to help you avoid errors when writing your reducer.
## 💪 Motivation
Turn this 💩 💩 💩
```ts
interface SetFirstNameAction {
type: "SET_FIRST_NAME";
firstName: string;
}interface SetLastNameAction {
type: "SET_LAST_NAME";
lastName: string;
}type Action = SetFirstNameAction | SetLastNameAction;
function reducer(action: Action, state: State): State {
switch (action.type) {
case "SET_FIRST_NAME":
return {
...state,
user: {
...state.user,
firstName: action.firstName,
},
};
case "SET_LAST_NAME":
return {
...state,
user: {
...state.user,
lastName: action.lastName,
},
};
default:
return state;
}
}
```✨✨ Into this! ✨✨
```ts
import {ImmerReducer} from "immer-reducer";class MyImmerReducer extends ImmerReducer {
setFirstName(firstName: string) {
this.draftState.user.firstName = firstName;
}setLastName(lastName: string) {
this.draftState.user.lastName = lastName;
}
}
```🔥🔥 **Without losing type-safety!** 🔥🔥
Oh, and you get the action creators for free! 🤗 🎂
## 📖 Usage
Generate Action Creators and the actual reducer function for Redux from the class with
```ts
import {createStore} from "redux";
import {createActionCreators, createReducerFunction} from "immer-reducer";const initialState: State = {
user: {
firstName: "",
lastName: "",
},
};const ActionCreators = createActionCreators(MyImmerReducer);
const reducerFunction = createReducerFunction(MyImmerReducer, initialState);const store = createStore(reducerFunction);
```Dispatch some actions
```ts
store.dispatch(ActionCreators.setFirstName("Charlie"));
store.dispatch(ActionCreators.setLastName("Brown"));expect(store.getState().user.firstName).toEqual("Charlie");
expect(store.getState().user.lastName).toEqual("Brown");
```## 🌟 Typed Action Creators!
The generated `ActionCreator` object respect the types used in the class
```ts
const action = ActionCreators.setFirstName("Charlie");
action.payload; // Has the type of stringActionCreators.setFirstName(1); // Type error. Needs string.
ActionCreators.setWAT("Charlie"); // Type error. Unknown method
```If the reducer class where to have a method which takes more than one argument
the payload would be array of the arguments```ts
// In the Reducer class:
// setName(firstName: string, lastName: string) {}
const action = ActionCreators.setName("Charlie", "Brown");
action.payload; // will have value ["Charlie", "Brown"] and type [string, string]
```The reducer function is also typed properly
```ts
const reducer = createReducerFunction(MyImmerReducer);reducer(initialState, ActionCreators.setFirstName("Charlie")); // OK
reducer(initialState, {type: "WAT"}); // Type error
reducer({wat: "bad state"}, ActionCreators.setFirstName("Charlie")); // Type error
```## ⚓ React Hooks
Because the `useReducer()` API in React Hooks is the same as with Redux
Reducers immer-reducer can be used with as is.```tsx
const initialState = {message: ""};class ReducerClass extends ImmerReducer {
setMessage(message: string) {
this.draftState.message = message;
}
}const ActionCreators = createActionCreators(ReducerClass);
const reducerFunction = createReducerFunction(ReducerClass);function Hello() {
const [state, dispatch] = React.useReducer(reducerFunction, initialState);return (
{
dispatch(ActionCreators.setMessage("Hello!"));
}}
>
{state.message}
);
}
```The returned state and dispatch functions will be typed as you would expect.
## 🤔 How
Under the hood the class is deconstructed to following actions:
```js
{
type: "IMMER_REDUCER:MyImmerReducer#setFirstName",
payload: "Charlie",
}
{
type: "IMMER_REDUCER:MyImmerReducer#setLastName",
payload: "Brown",
}
{
type: "IMMER_REDUCER:MyImmerReducer#setName",
payload: ["Charlie", "Brown"],
args: true
}
```So the class and method names become the Redux Action Types and the method
arguments become the action payloads. The reducer function will then match
these actions against the class and calls the appropriate methods with the
payload array spread to the arguments.🚫 The format of the `action.type` string is internal to immer-reducer. If
you need to detect the actions use the provided type guards.The generated reducer function executes the methods inside the `produce()`
function of Immer enabling the terse mutatable style updates.## 🔄 Integrating with the Redux ecosystem
To integrate for example with the side effects libraries such as
[redux-observable](https://github.com/redux-observable/redux-observable/) and
[redux-saga](https://github.com/redux-saga/redux-saga), you can access the
generated action type using the `type` property of the action creator
function.With redux-observable
```ts
// Get the action name to subscribe to
const setFirstNameActionTypeName = ActionCreators.setFirstName.type;// Get the action type to have a type safe Epic
type SetFirstNameAction = ReturnType;const setFirstNameEpic: Epic = action$ =>
action$
.ofType(setFirstNameActionTypeName)
.pipe(
// action.payload - recognized as string
map(action => action.payload.toUpperCase()),
...
);
```With redux-saga
```ts
function* watchFirstNameChanges() {
yield takeEvery(ActionCreators.setFirstName.type, doStuff);
}// or use the isActionFrom() to get all actions from a specific ImmerReducer
// action creators object
function* watchImmerActions() {
yield takeEvery(
(action: Action) => isActionFrom(action, MyImmerReducer),
handleImmerReducerAction,
);
}function* handleImmerReducerAction(action: Actions) {
// `action` is a union of action types
if (isAction(action, ActionCreators.setFirstName)) {
// with action of setFirstName
}
}
```**Warning:** Due to how immer-reducers action generation works, adding default
parameters to the methods will NOT pass it to the action payload, which can
make your reducer impure and the values will not be available in middlewares.```ts
class MyImmerReducer extends ImmerReducer {
addItem (id: string = uuid()) {
this.draftState.ids.push([id])
}
}immerActions.addItem() // generates empty payload { payload: [] }
```As a workaround, create custom action creator wrappers that pass the default parameters instead.
```ts
class MyImmerReducer extends ImmerReducer {
addItem (id) {
this.draftState.ids.push([id])
}
}const actions = {
addItem: () => immerActions.addItem(id)
}
```It is also recommended to install the ESLint plugin in the "Install" section
to alert you if you accidentally encounter this issue.## 📚 Examples
Here's a more complete example with redux-saga and [redux-render-prop](https://github.com/epeli/redux-render-prop):
## 🃏 Tips and Tricks
You can replace the whole `draftState` with a new state if you'd like. This could be useful if you'd like to reset back to your initial state.
```ts
import {ImmerReducer} from "immer-reducer";const initialState: State = {
user: {
firstName: "",
lastName: "",
},
};class MyImmerReducer extends ImmerReducer {
// omitting other reducer methods
reset() {
this.draftState = initialState;
}
}
```## 📓 Helpers
The module exports following helpers
### `function isActionFrom(action, ReducerClass)`
Type guard for detecting whether the given action is generated by the given
reducer class. The detected type will be union of actions the class
generates.Example
```ts
if (isActionFrom(someAction, ActionCreators)) {
// someAction now has type of
// {
// type: "setFirstName";
// payload: string;
// } | {
// type: "setLastName";
// payload: string;
// };
}
```### `function isAction(action, actionCreator)`
Type guard for detecting specific actions generated by immer-reducer.
Example
```ts
if (isAction(someAction, ActionCreators.setFirstName)) {
someAction.payload; // Type checks to `string`
}
```### `type Actions`
Get union of the action types generated by the ImmerReducer class
Example
```ts
type MyActions = Actions;// Is the same as
type MyActions =
| {
type: "setFirstName";
payload: string;
}
| {
type: "setLastName";
payload: string;
};
```### `function setPrefix(prefix: string)`
The default prefix in the generated action types is `IMMER_REDUCER`. Call
this customize it for your app.Example
```ts
setPrefix("MY_APP");
```### `function composeReducers(...reducers)`
Utility that reduces actions by applying them through multiple reducers.
This helps in allowing you to split up your reducer logic to multiple `ImmerReducer`s
if they affect the same part of your stateExample
```ts
class MyNameReducer extends ImmerReducer {
setFirstName(firstName: string) {
this.draftState.firstName = firstName;
}setLastName(lastName: string) {
this.draftState.lastName = lastName;
}
}class MyAgeReducer extends ImmerReducer {
setAge(age: number) {
this.draftState.age = 8;
}
}export const reducer = composeReducers(
createReducerFunction(MyNameReducer, initialState),
createReducerFunction(MyAgeReducer, initialState)
)
```