Repository: ctrlplusb/easy-peasy (js)

Issue:
integrate third-party redux tools with createStore
How would one implement https://github.com/supasate/connected-react-router with easy-peasy -- specifically around the createStore documentation?

easy-peasy has it's own createStore function that accepts an object, but redux's createStore accepts a function. I'd like to know how to integrate modules like connected-react-router with easy-peasy (if it's possible).

I apologize this isn't an "issue", however it's unclear from the documentation on how to accomplish this. I'll make a PR to update the readme based on feedback from this issue.

Pull request description:
Adds reducer helper
Released as 1.6.0-alpha.1

Provides the ability to define a custom "standard" reducer:

```javascript
import { createStore, reducer } from 'easy-peasy';

const store = createStore({
  counter: reducer((state = 1, action) => {
    switch (action.type) {
      case 'INCREMENT': state + 1;  	
      default: return state;
    }
  })
});

store.dispatch({ type: 'INCREMENT' });

store.getState().counter;
// 2
```

Closes #25 

Patch:
diff --git a/README.md b/README.md
index 3ff2aea..6d18111 100644
--- a/README.md
+++ b/README.md
@@ -92,6 +92,7 @@ function TodoList() {
     - [createStore(model, config)](#createstoremodel-config)
     - [action](#action)
     - [effect(action)](#effectaction)
+    - [reducer(fn)](#reducerfn)
     - [select(selector)](#selectselector)
     - [StoreProvider](#storeprovider)
     - [useStore(mapState, externals)](#usestoremapstate-externals)
@@ -679,6 +680,54 @@ const store = createStore(
 store.dispatch.doSomething()
 ```
 
+### reducer(fn)
+
+Declares a section of state that is derived via the given selector function. This was specifically added to allow for integrations with existing libraries, or legacy Redux code.
+
+Some 3rd party libraries, for example [`connected-react-router`](https://github.com/supasate/connected-react-router), require you to attach a reducer that they provide to your state. This helper will you achieve this.
+
+#### Arguments
+
+  - fn (Function, required)
+
+    The reducer function. It receives the following arguments.
+
+    - `state` (Object, required)
+
+      The current value of the property that the reducer was attached to.
+
+    - `action` (Object, required)
+
+      The action object, typically with the following shape.
+
+      - `type` (string, required)
+
+        The name of the action.
+
+      - `payload` (any)
+
+        Any payload that was provided to the action.
+
+#### Example
+
+```javascript
+import { createStore, reducer } from 'easy-peasy';
+
+const store = createStore({
+  counter: reducer((state = 1, action) => {
+    switch (action.type) {
+      case 'INCREMENT': state + 1;
+      default: return state;
+    }
+  })
+});
+
+store.dispatch({ type: 'INCREMENT' });
+
+store.getState().counter;
+// 2
+```
+
 ### select(selector)
 
 Declares a section of state that is derived via the given selector function.
diff --git a/package.json b/package.json
index c878dde..abbb44c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "easy-peasy",
-  "version": "1.5.0",
+  "version": "1.6.0-alpha.1",
   "description": "Easy peasy global state for React",
   "license": "MIT",
   "main": "dist/easy-peasy.cjs.js",
diff --git a/src/easy-peasy.js b/src/easy-peasy.js
index 2fea382..53ac3a3 100644
--- a/src/easy-peasy.js
+++ b/src/easy-peasy.js
@@ -12,6 +12,7 @@ const effectSymbol = '__effect__'
 const selectSymbol = '__select__'
 const selectDependeciesSymbol = '__selectDependencies__'
 const selectStateSymbol = '__selectState__'
+const reducerSymbol = '__reducer__'
 
 const get = (path, target) =>
   path.reduce((acc, cur) => (isStateObject(acc) ? acc[cur] : undefined), target)
@@ -46,6 +47,12 @@ export const select = (fn, dependencies) => {
   return selector
 }
 
+export const reducer = fn => {
+  // eslint-disable-next-line no-param-reassign
+  fn[reducerSymbol] = true
+  return fn
+}
+
 export const createStore = (model, options = {}) => {
   const {
     devTools = true,
@@ -68,6 +75,7 @@ export const createStore = (model, options = {}) => {
   const actionEffects = {}
   const actionCreators = {}
   const actionReducers = {}
+  const customReducers = []
   const selectorReducers = []
 
   const extract = (current, parentPath) =>
@@ -103,6 +111,8 @@ export const createStore = (model, options = {}) => {
           set(path, actionCreators, payload =>
             tick().then(() => references.dispatch(() => action(payload))),
           )
+        } else if (value[reducerSymbol]) {
+          customReducers.push({ path, reducer: value })
         } else {
           // Reducer Action
           const actionName = `@action.${path.join('.')}`
@@ -140,53 +150,70 @@ export const createStore = (model, options = {}) => {
 
   extract(definition, [])
 
-  const createReducers = (current, path) => {
-    const actionReducersAtPath = Object.keys(current).reduce((acc, key) => {
-      const value = current[key]
-      if (typeof value === 'function' && !value[effectSymbol]) {
-        return [...acc, value]
-      }
-      return acc
-    }, [])
-    const stateAtPath = Object.keys(current).reduce(
-      (acc, key) => (isStateObject(current[key]) ? [...acc, key] : acc),
-      [],
-    )
-    const nestedReducers = stateAtPath.map(key => [
-      key,
-      createReducers(current[key], [...path, key]),
-    ])
-    const reducerForActions = (state = get(path, defaultState), action) => {
-      // short circuit effects as they are noop in reducers
-      if (startsWith(action.type, '@effect.')) {
-        return state
-      }
-      // short circuit actions if they aren't a match on current path
-      if (
-        path.length > 0 &&
-        !startsWith(action.type, `@action.${path.join('.')}`)
-      ) {
-        return state
-      }
-      const actionReducer = actionReducersAtPath.find(
-        x => x.actionName === action.type,
+  const createReducers = () => {
+    const createActionsReducer = (current, path) => {
+      const actionReducersAtPath = Object.keys(current).reduce((acc, key) => {
+        const value = current[key]
+        if (typeof value === 'function' && !value[effectSymbol]) {
+          return [...acc, value]
+        }
+        return acc
+      }, [])
+      const stateAtPath = Object.keys(current).reduce(
+        (acc, key) => (isStateObject(current[key]) ? [...acc, key] : acc),
+        [],
       )
-      if (actionReducer) {
-        return actionReducer(state, action.payload)
-      }
-      for (let i = 0; i < nestedReducers.length; i += 1) {
-        const [key, reducer] = nestedReducers[i]
-        const newState = reducer(state[key], action)
-        if (state[key] !== newState) {
-          return {
-            ...state,
-            [key]: newState,
+      const nestedReducers = stateAtPath.map(key => [
+        key,
+        createActionsReducer(current[key], [...path, key]),
+      ])
+      return (state = get(path, defaultState), action) => {
+        // short circuit effects as they are noop in reducers
+        if (startsWith(action.type, '@effect.')) {
+          return state
+        }
+        // short circuit actions if they aren't a match on current path
+        if (
+          path.length > 0 &&
+          !startsWith(action.type, `@action.${path.join('.')}`)
+        ) {
+          return state
+        }
+        const actionReducer = actionReducersAtPath.find(
+          x => x.actionName === action.type,
+        )
+        if (actionReducer) {
+          return actionReducer(state, action.payload)
+        }
+        for (let i = 0; i < nestedReducers.length; i += 1) {
+          const [key, red] = nestedReducers[i]
+          const newState = red(state[key], action)
+          if (state[key] !== newState) {
+            return {
+              ...state,
+              [key]: newState,
+            }
           }
         }
+        return state
       }
-      return state
     }
-    let isInitial = true
+
+    const reducerForActions = createActionsReducer(actionReducers, [])
+
+    const reducerWithCustom =
+      customReducers.length > 0
+        ? (state, action) => {
+            const stateAfterActions = reducerForActions(state, action)
+            return produce(stateAfterActions, draft => {
+              customReducers.forEach(({ path: p, reducer: red }) => {
+                const current = get(p, draft)
+                set(p, draft, red(current, action))
+              })
+            })
+          }
+        : reducerForActions
+
     const runSelectorReducer = (state, selector) => {
       const { parentPath, key, executed } = selector[selectStateSymbol]
       if (executed) {
@@ -207,11 +234,15 @@ export const createStore = (model, options = {}) => {
       selector[selectStateSymbol].executed = true
       return newState
     }
+
     const runSelectors = state =>
       selectorReducers.reduce(runSelectorReducer, state)
-    return selectorReducers.length
+
+    let isInitial = true
+
+    return selectorReducers.length > 0
       ? (state, action) => {
-          const stateAfterActions = reducerForActions(state, action)
+          const stateAfterActions = reducerWithCustom(state, action)
           if (state !== stateAfterActions || isInitial) {
             const stateAfterSelectors = runSelectors(stateAfterActions)
             isInitial = false
@@ -223,10 +254,10 @@ export const createStore = (model, options = {}) => {
           }
           return stateAfterActions
         }
-      : reducerForActions
+      : reducerWithCustom
   }
 
-  const reducers = createReducers(actionReducers, [])
+  const reducers = createReducers()
 
   const composeEnhancers =
     compose ||
diff --git a/src/index.js b/src/index.js
index 0bbd533..913dbca 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,5 +1,13 @@
-import { createStore, effect, select } from './easy-peasy'
+import { createStore, effect, reducer, select } from './easy-peasy'
 import { useStore, useAction } from './hooks'
 import StoreProvider from './provider'
 
-export { createStore, effect, select, useStore, useAction, StoreProvider }
+export {
+  createStore,
+  effect,
+  reducer,
+  select,
+  StoreProvider,
+  useAction,
+  useStore,
+}


Test patch:
diff --git a/src/__tests__/easy-peasy.test.js b/src/__tests__/easy-peasy.test.js
index 1399a8f..87be49f 100644
--- a/src/__tests__/easy-peasy.test.js
+++ b/src/__tests__/easy-peasy.test.js
@@ -9,6 +9,7 @@ import {
   StoreProvider,
   createStore,
   effect,
+  reducer,
   select,
   useStore,
   useAction,
@@ -925,3 +926,88 @@ describe('dependency injection', () => {
     expect(injection).toHaveBeenCalledTimes(1)
   })
 })
+
+describe('reducer', () => {
+  it('basic', () => {
+    // arrange
+    const store = createStore({
+      counter: reducer((state = 1, action) => {
+        if (action.type === 'INCREMENT') {
+          return state + 1
+        }
+        return state
+      }),
+      foo: {
+        bar: 'baz',
+        update: state => {
+          state.bar = 'bob'
+        },
+      },
+    })
+
+    // assert
+    expect(store.getState().counter).toEqual(1)
+
+    // act
+    store.dispatch({ type: 'INCREMENT' })
+
+    // assert
+    expect(store.getState()).toEqual({
+      counter: 2,
+      foo: {
+        bar: 'baz',
+      },
+    })
+  })
+
+  it('nested', () => {
+    // arrange
+    const store = createStore({
+      stuff: {
+        counter: reducer((state = 1, action) => {
+          if (action.type === 'INCREMENT') {
+            return state + 1
+          }
+          return state
+        }),
+      },
+    })
+
+    // act
+    store.dispatch({ type: 'INCREMENT' })
+
+    // assert
+    expect(store.getState()).toEqual({
+      stuff: {
+        counter: 2,
+      },
+    })
+  })
+
+  it('with selector', () => {
+    // arrange
+    const store = createStore({
+      products: reducer((state = [], { type, payload }) => {
+        if (type === 'ADD_PRODUCT') {
+          return [...state, payload]
+        }
+        return state
+      }),
+      totalPrice: select(state =>
+        state.products.reduce((acc, cur) => acc + cur.price, 0),
+      ),
+    })
+
+    // act
+    store.dispatch({
+      type: 'ADD_PRODUCT',
+      payload: { name: 'Boots', price: 10 },
+    })
+
+    // assert
+    expect(store.getState()).toEqual({
+      products: [{ name: 'Boots', price: 10 }],
+      totalPrice: 10,
+    })
+  })
+})