Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More helpers v2 #3

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .babelrc
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"ignore": [],
"plugins": [
"dev-expression"
],
"env": {
"test": {
"presets": [
Expand Down
338 changes: 275 additions & 63 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@

Helpers for scaling and abstracting redux by co-locating actions, reducers and selectors.

[![Build Status](https://travis-ci.org/thomasdashney/redux-modular.svg?branch=master)](https://travis-ci.org/thomasdashney/redux-modular) [![Test Coverage](https://codeclimate.com/github/thomasdashney/redux-modular/badges/coverage.svg)](https://codeclimate.com/github/thomasdashney/redux-modular/coverage) [![Code Climate](https://codeclimate.com/github/thomasdashney/redux-modular/badges/gpa.svg)](https://codeclimate.com/github/thomasdashney/redux-modular)
[![Build Status](https://travis-ci.org/thomasdashney/redux-modular.svg?branch=master)](https://travis-ci.org/thomasdashney/redux-modular) [![Test Coverage](https://co`dec`limate.com/github/thomasdashney/redux-modular/badges/coverage.svg)](https://codeclimate.com/github/thomasdashney/redux-modular/coverage) [![Code Climate](https://codeclimate.com/github/thomasdashney/redux-modular/badges/gpa.svg)](https://codeclimate.com/github/thomasdashney/redux-modular)

* [Install](#install)
* [Usage Guide](#usage-guide)
* [Defining actions](#defining-actions)
* [Defining reducers](#defining-reducers)
* [Defining selectors](#defining-selectors)
* [Defining reusable redux logic](#defining-reusable-redux-logic)
* [Writing tests](#writing-tests)
* [API](#api)

## Install

Expand All @@ -16,96 +25,299 @@ or
$ yarn add redux-modular
```

## Usage
## Usage Guide

This guide uses a counter as an example, which starts at 0 and ends at 10. If you try to `increment` past the 10, it will stay at 10.

Two selectors are provided: `value` which gets the current value of the counter, and `isComplete`, which return `true` if the counter has reached 10 (the maximum). Finally, there is a `reset` action for resetting back to the initial state of `0`.

<p align="center">
<img src="https://raw.githubusercontent.com/thomasdashney/redux-modular/master/counter-example.png" />
</p>
Here is how one might implement this using plain redux:

```js
import { combineReducers, createStore } from 'redux'
import { mount, createReducer } from 'redux-modular'

/* Create an object containing the logic (actions, reducer, selectors) */

const counter = {
// mapping of action names to optional payload creators
actions: {
increment: null,
decrement: null,
set: (value) => ({ value })
},

// function mapping actions to reducers
reducer: actions => createReducer(0, {
[actions.increment]: state => state + 1,
[actions.decrement]: state => state - 1,
[actions.set]: (state, payload) => payload.value
}),

// function mapping local state selector to your selectors
selectors: localStateSelector => ({
counterValue: state => localStateSelector(state)
})
import { createStore, combineReducers } from 'redux'

const INITIAL_STATE = 0
const COUNTER_MAX = 10

const COUNTER_TYPES = {
INCREMENT: 'increment (counter)',
RESET: 'reset (counter)'
}

/* Instantiate the counter logic by mounting to redux paths */
const counterActions = {
increment: (amount = 1) => ({ type: COUNTER_TYPES.INCREMENT, payload: { amount } }),
reset: () => ({ type: COUNTER_TYPES.RESET })
}

const counterReducer = (state = INITIAL_STATE, action) => {
switch (action.type) {
case COUNTER_TYPES.INCREMENT:
return Math.min(state + action.payload.amount, COUNTER_MAX)
case COUNTER_TYPES.RESET:
return INITIAL_STATE
default:
return state
}
}

const counter1 = mount('counter1', counter)
const counter2 = mount('counter2', counter)
const counter3 = mount(['nested', 'counter3'], counter)
const counterSelectors = {
value: state => state.counter,
isComplete: state => state.counter === COUNTER_MAX
}

/* Add the reducers to your root reducer */
// then the reducer would be mounted to the store at `state.counter`:

const rootReducer = combineReducers({
counter1: counter1.reducer,
counter2: counter2.reducer,
nested: combineReducers({
counter3: counter3.reducer
const store = createStore(
combineReducers({
counter: counterReducer
})
)

counterSelectors.value(store.getState()) // 0
counterSelectors.isComplete(store.getState()) // true

store.dispatch(counterActions.increment(10))
counterSelectors.value(store.getState()) // 10
counterSelectors.isComplete(store.getState()) // true

store.dispatch(counterActions.reset())
counterSelectors.value(store.getState()) // 0
counterSelectors.isComplete(store.getState()) // false
```

Each section in this guide shows how each `redux-modular` helper function can be used to reduce code repetition and boilerplate.

### Defining actions

Using `createAction`, we can easily define `FSA-Compliant` action creator:

```js
import { createAction } from 'redux-modular'

const counterActions = {
increment: createAction(COUNTER_TYPES.INCREMENT, (amount = 1) => ({ amount })),
reset: createAction(COUNTER_TYPES.RESET)
}
```

`createAction` also does some extra work for us. If you call `toString()` on any of these action creators, it will return the action's type. This allows us to remove our `COUNTER_TYPES` constant declaration:

```js
import { createAction } from 'redux-modular'

const counterActions = {
increment: createAction('increment (counter)', (amount = 1) => ({ amount })),
reset: createAction('reset (counter)')
}

counterActions.increment.toString() // 'increment (counter)'
```

Next, we can remove the repetition of namespacing our actions with the `(counter)` suffix. The `mountAction` helper will modify an action creator's action type using a given namespace:

```js
import { createAction, mountAction } from 'redux-modular'

const namespace = 'counter'

const counterActions = {
increment: mountAction(namespace, createAction('increment', (amount = 1) => ({ amount })))
reset: mountAction(createAction('reset')
})

counterActions.increment.toString() // 'increment (counter)'
```

Finally, we can use the `createActions` and `mountActions` helpers to reduce all of the above boilerplate:

```js
import { createActions, mountActions } from 'redux-modular'

const counterActions = mountActions('counter', createActions({
increment: (amount = 1) => ({ amount }),
reset
}))
```

### Defining reducers

`createReducer` creates a reducer which switches based on action type, and passes the action `payload` directly to your sub-reducer function:

```js
import { createReducer } from 'redux-modular'

const INITIAL_STATE = 0
const COUNTER_MAX = 10

const counterReducer = createReducer(INITIAL_STATE, {
[counterActions.increment]: (state, payload) => Math.min(state + payload.amount, COUNTER_MAX),
[counterActions.reset]: () => INITIAL_STATE
})
```

Note that, because we passed the action creators directly as the object keys, the `toString()` function will be called on them automatically.

### Defining selectors

const store = createStore(rootReducer)
`mountSelector` will wrap a selector with a selector which first selects the state at a provided path:

/* Use actions and selectors for each counter instance in your app */
```js
const isCompleteSelector = mountSelector('counter', counterState => counterState === COUNTER_MAX)

isCompleteSelector({ counter: 5 }) // 5
```

const { actions, selectors } = counter1
`mountSelectors` allows you to mount multiple selectors at once:

console.log(selectors.counterValue(store.getState())) // prints `0`
```js
const counterSelectors = mountSelectors('counter', {
value: counterState => counterState,
isComplete: counterState => counterState === COUNTER_MAX
})
```

store.dispatch(actions.increment())
console.log(selectors.counterValue(store.getState())) // prints `1`
If our logic lives multiple levels deep in the redux state tree, you can use [lodash.get](https://lodash.com/docs/4.17.10#get) syntax to perform a deep select:

store.dispatch(actions.decrement())
console.log(selectors.counterValue(store.getState())) // prints `0`
```js
const counterSelectors = mountSelectors('path.counter', {
value: counterState => counterState,
isComplete: counterState => counterState === COUNTER_MAX
})

store.dispatch(actions.set(5))
console.log(selectors.counterValue(store.getState())) // prints `5`
counterSelectors.value({ nested: { counter: 5 } }) // 5
```

## Writing Tests
### Defining reusable redux logic

If you `mount` your logic to a path of `null`, you can test your state logic without any assumption of where it sits in your redux state.
Putting the above examples together, we have reduced much boilerplate & repetition:

```js
/* eslint-env jest */
import { createActions, mountActions, createReducer, mountSelectors } from 'redux-modular'

const counter = require('./counter')
const REDUX_PATH = 'counter'
const INITIAL_STATE = 0
const COUNTER_MAX = 10

const { actions, reducer, selectors } = mount(null, counter)
const counterActions = mountActions(REDUX_PATH, createActions({
increment: (amount = 1) => ({ amount }),
}))

it('can increment', () => {
const state = reducer(0, actions.increment())
expect(selectors.counterValue(state)).toEqual(1)
const counterReducer = createReducer(INITIAL_STATE, {
[counterActions.increment]: (state, payload) => Math.min(state + payload.amount, COUNTER_MAX),
[counterActions.reset]: () => INITIAL_STATE
})

it('can decrement', () => {
const state = reducer(0, actions.decrement())
expect(selectors.counterValue(state)).toEqual(-1)
const counterSelector = mountSelectors(REDUX_PATH, {
value: counterState => counterState,
isComplete: counterState => counterState === COUNTER_MAX
})
```

However, what if we wanted to reuse this logic in multiple places in the redux state tree? We can easily wrap these definitions in a factory function, with the path and counter max as parameters:

```js
// create-counter-logic.js
import { createActions, mountActions } from 'redux-modular'

const INITIAL_STATE = 0

export default function createCounterLogic (path, counterMax) {
const actions = mountActions(path, createActions({
increment: (amount = 1) => ({ amount }),
}))

const reducer = createReducer(INITIAL_STATE, {
[counterActions.increment]: (state, payload) => Math.min(state + payload.amount, counterMax),
[counterActions.reset]: () => INITIAL_STATE
})

const selectors = mountSelectors(path, {
value: counterState => counterState,
isComplete: counterState => counterState === counterMax
})

return {
actions,
reducer,
selectors
}
}
```

it('can be set to a number', () => {
const state = reducer(0, actions.set(5))
expect(selectors.counterValue(state)).toEqual(5)
Now, we can quickly and easily instantiate our logic and mount it multiple places in our redux state tree:

```js
import { createStore,combineReducers } from 'redux'
import createCounterLogic from './create-counter-logic.js'

const counterTo5 = createCounterLogic('counterTo5', 0, 5)
const counterTo10 = createCounterLogic('counterTo10', 0, 10)

const store = createStore(
combineReducers({
counterTo5: counterTo5.reducer
nested: {
counterTo10: counterTo10.reducer
}
})
)

store.dispatch(counterTo5.actions.increment(5))
counterTo5.selectors.value(store.getState()) // 5
counterTo5.selectors.isComplete(store.getState()) // true

counterTo10.selectors.isComplete(store.getState()) // false
store.dispatch(counterTo5.actions.increment(10))
counterTo10.selectors.value(store.getState()) // 10
counterTo10.selectors.isComplete(store.getState()) // true
```

### Writing Tests

An easy, minimal way to test your logic is by running `actions` through the `reducer`, and making assertions about the return value of `selectors`. Here is an example using our
"counter" logic:

```js
import createCounterLogic from './create-counter-logic'

const COUNTER_MAX = 5

const {
counterActions,
counterSelectors,
counterReducer
} = createCounterLogic(null, COUNTER_MAX)

test('counter logic', () => {
let state = counterReducer(undefined, { type: '@@INIT' })
expect(counterSelectors.value(state)).toEqual(0)
expect(counterSelectors.isComplete(state)).toEqual(false)

state = counterReducer(state, counterActions.increment())
expect(counterSelectors.value(state)).toEqual(1)
expect(counterSelectors.isComplete(state)).toEqual(false)

state = counterReducer(state, counterActions.increment(4))
expect(counterSelectors.value(state)).toEqual(5)
expect(counterSelectors.isComplete(state)).toEqual(true)

state = counterReducer(state, counterActions.increment())
expect(counterSelectors.value(state)).toEqual(5) // shouldn't be able to increment past 5
})
```

## API

`createAction(String type, [Function payloadCreator]) : ActionCreator`

`createActions(Object<String type, Function payloadCreator> payloadCreatorMap) : Object<String, ActionCreator>`

`mountAction(String|Array<String> path, ActionCreator actionCreator) : ActionCreator`

`mountActions(Object<String, ActionCreator> actionCreatorMap) : Object<String, ActionCreator>`

`createReducer(Any initialState, Object<String type, Function reducer>) : Function reducer`

`mountSelector(String|Array<String> path, Function selector) : Function selector`

`mountSelectors(String|Array<String> path, Object<String, Function selector> selectorMap) : Object<String, Function selector>`
Loading