Skip to content

Commit

Permalink
switch to bindActionCreators and improve ActionReducerMap typing (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
sidecus authored Jul 13, 2020
1 parent e134849 commit da1a63f
Show file tree
Hide file tree
Showing 13 changed files with 197 additions and 215 deletions.
10 changes: 4 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ node_modules
build
dist
.rpt2_cache
.rts2_cache_*
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# misc
.DS_Store
Expand All @@ -16,9 +20,3 @@ dist
.env.development.local
.env.test.local
.env.production.local

npm-debug.log*
yarn-debug.log*
yarn-error.log*
.rts2_cache_*
react-app-env.d.ts
20 changes: 20 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug test",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run-script", "test",
"--inspect-brk=5858"
],
"port": 5858
}
]
}
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules\\typescript\\lib"
}
79 changes: 41 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# roth.js

> roth.js is a tiny Javascript library to help improve code readability when dispatching Redux actions. It also provides an opioninated sliced reducer api to help avoid big switches in reducer definitions.
> Tiny react-redux extension library for easier action/dispatch/reducer management
[![NPM](https://img.shields.io/npm/v/roth.js.svg)](https://www.npmjs.com/package/roth.js) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)![CI](https://github.com/sidecus/roth.js/workflows/CI/badge.svg?branch=master)

Expand All @@ -11,55 +11,58 @@ npm install --save roth.js
```

## Usage
### createActionCreator and useBoundActions
First define your actions with ```createActionCreator```:
```TS
export const updateState = createActionCreator<'UpdateState');
export const resetState = createActionCreator('ResetState');
### **useBoundActions**
Dispatch actions and thunk easily with useBoundActions convenience api:
```tsx
// **** action.ts ****
// Call useBoundActions hook to automatically bind action creators to dispatch.
// Here we are creating a custom hook named useAreaActions from it.
const areaActionCreators = { setNumber, setString };
export const useAreaActions = () => useBoundActions(areaActionCreators);

// Define a global object which contains the action creators.
// Generate dispatch bound versions of the actions and expose it as one object with useMyBoundActions hook
const namedActionCreators = { updateState, resetState };
export const useMyBoundActions = () => useBoundActions(namedActionCreators);
```
Now in your own component, instead of:
```TSX
import { updateState, updateState /*and other actions*/ } from './actions';
export const SomeComponent = () => {
const dispatch = useDispatch();
return (
<button onclick={() => dispatch(updateState(Math.random()))}>Update State</button>
<button onclick={() => dispatch(resetState())}>Reset State</button>
);
```
You can do this - note there is **no dispatch** and code is a bit more natural to read:
```TSX
import { useMyBoundActions } from './actions';
// **** SomeComponent.tsx ****
export const SomeComponent = () => {
const { updateState, resetState } = useMyBoundActions();
const { setNumber, setString } = useAreaActions();
return (
<button onclick={() => updateState(Math.random())}>Update State</button>
<button onclick={resetState}>Reset State</button>
<>
<button onclick={() => setNumber(someNumber)}>SetNumber</button>
<button onclick={() => setString(someString))}>SetString</button>
</>
);
};
```
Here is a full sample project using this to implement a TODO app: [Code sample](https://github.com/sidecus/reactstudy/tree/master/src/ReduxHooks). The sample project also leverages other popular libraries e.g. reselect.js/redux-thunk etc.
Compared to the tranditional way (```useDispatch``` and then ```dispatch(numberAction(someNumber))``` ), the code is shorter, easier to read, and also convenient to unit test as well since you can just mock the new custom hook without having to worry about mocking dispatch.
A full sample project using this to implement a TODO app can be found [here](https://github.com/sidecus/reactstudy/tree/master/src/ReduxHooks). The sample project also leverages other popular libraries e.g. reselect.js/redux-thunk etc.

### Opininated bonus api createSlicedReducer
Use *createSlicedReducer* to glue reducers on the same sliced state without having to use switch statements. This can also be achieved with combineReducers, but it might lead to small granular and verbose state definition.
```typescript
### **createActionCreator**
If you are using actions with action type as string and with none or single payload, you can define your actions with the built in ```createActionCreator``` api easily:
```tsx
export const noPayloadAction = createActionCreator('NoPayloadAction');
export const stringPayloadAction = createActionCreator<string>('StringAction');
export const customPayloadAction = createActionCreator<MyPayload>('MyPayloadAction');
```

### **createSlicedReducer**
When creating reducers, it's common that we use if/else or switch statements to check action type and reduce based on that. ```createSlicedReducer``` api can help make that easier without branching statements. It handles action to reducer mapping for your automatically based on the passed in map. The return type of ```createSlicedReducer``` is a reducer by itself and you can pass that into combinedReducers.
```tsx
// Define reducers.
const myStateReducer: Reducer<MyState, MyStateActions> = (state, action) => {...};
const myReducer = createSlicedReducer(
DefaultState1, {
[MyActions.UPDATE_STATE_1]: [updateState1Reducer],
[MyActions.RESET_BOTH_STATES]: [resetState1Reducer]
const numberReducer: Reducer<MyState, Action<number>> = ...;
const numberReducer2: Reducer<MyState, Action<number>> = ...;
const stringReducer: Reducer<MyState, Action<string>> = ...;

// Create a sliced reducer on current state slice
const mySlicedReducer = createSlicedReducer(DefaultMyState, {
[MyActions.NumberAction]: [numberReducer, numberReducer2],
[MyActions.StringAction]: [stringReducer]
});

// Get root reducer and construct store as what you normally do
const rootReducer = combineReducers({ state1: myReducer, state2: someOtherReducer});
// Get root reducer and construct store as usual
const rootReducer = combineReducers({ myState: mySlicedReducer, state2: otherReducer });
export const store = createStore(rootReducer, composeWithDevTools(applyMiddleware(thunk)));
```

## Changelog
v3.0.0: Switch to bindActionCreators to further reduce package size and improve typing for ActionReducerMap.

# Happy coding. Peace.
MIT © [sidecus](https://github.com/sidecus)
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 12 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
{
"name": "roth.js",
"version": "2.0.3",
"description": "roth.js (Redux On The Hooks) is a library to help simplify redux action and state management using React Hooks.",
"version": "3.0.0",
"description": "roth.js - Tiny react-redux extension library for easier action/dispatch/reducer management",
"author": "sidecus",
"license": "MIT",
"repository": "sidecus/roth.js",
"keywords": [
"hooks",
"redux",
"react",
"dispatch",
"actions",
"store",
"reducer",
"react-redux",
"react",
"redux",
"roth.js"
],
"main": "dist/index.js",
Expand All @@ -22,12 +22,12 @@
"node": ">=10"
},
"scripts": {
"build": "microbundle",
"start": "microbundle --no-compress",
"build": "microbundle --tsconfig tsconfig.build.json",
"start": "microbundle --tsconfig tsconfig.build.json --no-compress",
"test": "run-s test:unit test:lint",
"test:lint": "eslint --ext ts .",
"test:unit": "cross-env CI=1 react-scripts test --env=jsdom",
"test:watch": "react-scripts test --env=jsdom"
"test:lint": "eslint --ext ts,tsx .",
"test:unit": "cross-env CI=1 tsc && react-scripts test --env=jsdom",
"test:watch": "tsc && react-scripts test --env=jsdom"
},
"peerDependencies": {
"react": "^16.0.0",
Expand Down Expand Up @@ -64,7 +64,8 @@
"react-redux": "^7.2.0",
"react-scripts": "^3.4.1",
"react-test-renderer": "^16.13.1",
"redux": "^4.0.5"
"redux": "^4.0.5",
"typescript": "3.9.2"
},
"files": [
"dist"
Expand Down
17 changes: 8 additions & 9 deletions src/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createActionCreator, useBoundActions, Action } from './index';
import { createActionCreator, useBoundActions } from './index';
import { renderHook } from '@testing-library/react-hooks';
import { useDispatch } from 'react-redux';
import { AnyAction } from 'redux';

jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
Expand All @@ -9,19 +10,16 @@ jest.mock('react-redux', () => ({

const useDispatchMock = useDispatch as jest.Mock;

const NumberAction = createActionCreator<number>('numberaction');
const StringAction = createActionCreator<string>('stringaction');
const VoidAction = createActionCreator('voidaction');
const actionCreators = {
numberAction: NumberAction,
stringAction: StringAction,
voidAction: VoidAction
numberAction: createActionCreator<number>('numberaction'),
stringAction: createActionCreator<string>('stringaction'),
voidAction: createActionCreator('voidaction')
};

describe('useMemoizedBoundActions behaviors', () => {
describe('useBoundActions behaviors', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dispatchResultRecorder = {} as any;
const fakeDispatch = (action: Action<string, unknown>) => {
const fakeDispatch = (action: AnyAction) => {
let payload = action.payload;
if (payload === undefined) {
payload = 'void';
Expand All @@ -32,6 +30,7 @@ describe('useMemoizedBoundActions behaviors', () => {
useDispatchMock.mockImplementation(() => fakeDispatch);

it('Glues dispatch and action creators', () => {
useDispatchMock.mockClear();
const { result } = renderHook(() => useBoundActions(actionCreators));

expect(result.error).toBeUndefined();
Expand Down
67 changes: 38 additions & 29 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Dispatch, combineReducers } from 'redux';
import { createActionCreator, Action, createSlicedReducer, Reducer, createdBoundActionCreators } from './index';
import { combineReducers } from 'redux';
import { createActionCreator, Action, createSlicedReducer, Reducer } from './index';

/**
* Test basic functionality for action creator, reducer and bound action creator
*/
describe('roth.js basic test', () => {
// test('reducer type tests', () => {
// const numberReducer = (s: number, a: Action<number>) => s + a.payload;
// const numberReducer2 = (s: number, a: Action<number>) => s - a.payload;
// const stringReducer = (s: string, a: Action<string>) => s + a.payload;
// const nopayloadstringReducer = (s: string, a: Action) => s + a.payload;

// // Map with diferent state type
// const map = {
// number: [numberReducer, numberReducer2],
// string: [stringReducer]
// };

// // Type error - Reducers with unmatching state type
// createSlicedReducer(3, map);

// // Map with different ation type
// const map2 = {
// StringAction: [stringReducer, nopayloadstringReducer]
// };

// const r = createSlicedReducer('', map2);
// // Type error below - action paramter is inconsistent
// r('state', {} as Action<string>);
// r('state', {} as Action);
// r('state', {} as Action<number>);
// });

test('createActionCreator creates proper action creators', () => {
// number as payload
const numberActionType = 'number action';
Expand All @@ -28,17 +55,17 @@ describe('roth.js basic test', () => {
}

test('createSlicedReducer creates correct reducer for a slice of the state', () => {
const numberReducer: Reducer<State, Action<string>> = (s: State) => {
const numberReducer: Reducer<State, Action> = (s: State) => {
s.numberField = s.numberField + 1;
return { ...s };
};

const numberReducer2: Reducer<State, Action<string>> = (s: State) => {
const numberReducer2: Reducer<State, Action> = (s: State) => {
s.numberField2 = s.numberField2 + 1;
return { ...s };
};

const stringReducer: Reducer<State, Action<string, string>> = (s, action) => {
const stringReducer: Reducer<State, Action<string>> = (s, action) => {
s.stringField = action.payload;
return { ...s };
};
Expand All @@ -51,45 +78,27 @@ describe('roth.js basic test', () => {
setstring: [stringReducer]
});

const addNumberAction = createActionCreator('addnumber')();
const setStringAction = createActionCreator<string>('setstring')('newstring');

// first reducer should update both numberField and numberField2
state = slicedReducer(state, { type: 'addnumber' } as Action<'addnumber'>);
state = slicedReducer(state, addNumberAction);
expect(state.numberField).toBe(1);
expect(state.numberField2).toBe(1);
expect(state.stringField).toBe('');

// should update both numberField and numberField2 again, and no change to stringField
state = slicedReducer(state, { type: 'addnumber' } as Action<'addnumber'>);
state = slicedReducer(state, addNumberAction);
expect(state.numberField).toBe(2);
expect(state.numberField2).toBe(2);
expect(state.stringField).toBe('');

// should only update stringField
state = slicedReducer(state, { type: 'setstring', payload: 'newstring' } as Action<'setstring', string>);
state = slicedReducer(state, setStringAction);
expect(state.numberField).toBe(2);
expect(state.numberField2).toBe(2);
expect(state.stringField).toBe('newstring');
});

// Fake dispatch which just returns the action object for UT purpose.
const dispatchMock: Dispatch<Action<string, unknown>> = <T extends Action<string, unknown>>(action: T) => action;

test('createdNamedBoundedActionCreators constructs correct named bounded action creator map', () => {
const result = createdBoundActionCreators(dispatchMock, {
setNumber: (p: number) => {
return { type: 'setNumber', payload: p };
},
setString: (p: string) => {
return { type: 'setString', payload: p };
},
noPayload: () => {
return { type: 'noPayload' } as Action<string>;
}
});

expect(result.setNumber(3)).toEqual({ type: 'setNumber', payload: 3 });
expect(result.setString('random')).toEqual({ type: 'setString', payload: 'random' });
expect(result.noPayload()).toEqual({ type: 'noPayload', payload: undefined });
});
});

/**
Expand Down
Loading

0 comments on commit da1a63f

Please sign in to comment.