Repository: reduxjs/redux-toolkit
Pull request #262 (2019-11-23 21:22:37)

alternative callback-builder-style notation for actionsMap
This is what I had suggested on twitter. Right now, it's just implemented on createReducer, but if you agree that we go forward with this, I'll add it to extraReducers, too.

netlify[bot]: Deploy preview for *redux-starter-kit-docs* ready!

Built with commit 9b18b99406ca74853a6526ea4e10e620b3648ada

https://deploy-preview-262--redux-starter-kit-docs.netlify.com
markerikson: Looks pretty neat.  I assume we wouldn't want to allow this for `createSlice`'s `reducers` arg, because the point of that is we're using that to _generate_ the action types, right?  This is only good for when we've already got action types defined and are now generating reducers to match them.
phryneas: > Looks pretty neat. I assume we wouldn't want to allow this for `createSlice`'s `reducers` arg, because the point of that is we're using that to _generate_ the action types, right? This is only good for when we've already got action types defined and are now generating reducers to match them.

Exactly. I'm seeing a use case for `createReducer`'s `actionMap`, and `createSlice`s `extraReducers`.

Maybe also for some kind of async middleware in the future.
markerikson: Yeah, go ahead and rig it up for `extraReducers`, then.

> Maybe also for some kind of async middleware in the future.

How so?
phryneas: Intersting sidenote: there's no codesandbox being generated for this. Maybe because it's a Draft PR? 🤔
phryneas: > Yeah, go ahead and rig it up for `extraReducers`, then.
> 
> > Maybe also for some kind of async middleware in the future.
> 
> How so?

The most simple async middleware I could think of would be something like an actionEffectsMap, either as an argument on `configureStore`, or even on a per-slice-level. Something like

```typescript
configureStore({
// ...
asyncEffects: {
  [todosRequested.type]:  async (action: ReturnType<typeof todosRequested>, { getState, dispatch }) => {
    // async stuff here
  } 
}
});
```

If we did something like that, that could benefit from a similar alternative notation.
markerikson: Hey, good point.

And yes, a simple action -> callback map is basically what I'm picturing.  Already opened up a threat to discuss it over in #237 .
markerikson: btw, after this, it'd be nice if we could add an actual "Usage with TypeScript" docs page that rephrases the info in the "Advanced Tutorial" and talks about using this builder.
phryneas: Yeah. Also, the docblocks will need some updating. Do you know how to correctly write docblocks for overloaded methods?
markerikson: Not specifically, no, and tbh the types I've listed in the API reference pages are sorta hand-wavy anyway (like references to a `ReducerFunction` type).

I'd say write the page for the callback builder first, then update the other couple pages to point to that and say that the args can either be a hand-written object or the result of the builder.
phryneas: Code-wise this should be it.

I'm gonna take a look at the docs tomorrow - I should get some sleep now :)
markerikson: @phryneas : maybe we lost the CodeSandbox integration due to the repo name change?

Patch:
diff --git a/README.md b/README.md
index 36046d15..9a478b3e 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,9 @@
 # Redux Toolkit
 
-[![build status](https://img.shields.io/travis/reduxjs/toolkit/master.svg?style=flat-square)](https://travis-ci.org/reduxjs/redux-toolkit)
+[![build status](https://img.shields.io/travis/reduxjs/redux-toolkit/master.svg?style=flat-square)](https://travis-ci.org/reduxjs/redux-toolkit)
 [![npm version](https://img.shields.io/npm/v/@reduxjs/toolkit.svg?style=flat-square)](https://www.npmjs.com/package/@reduxjs/toolkit)
-[![npm downloads](https://img.shields.io/npm/dm/@reduxjs/toolkit.svg?style=flat-square)](https://www.npmjs.com/package/@reduxjs/toolkit) (RTK)
-[![npm downloads](https://img.shields.io/npm/dm/redux-starter-kit.svg?style=flat-square)](https://www.npmjs.com/package/redux-starter-kit) (RSK)
+[![npm downloads](https://img.shields.io/npm/dm/@reduxjs/toolkit.svg?style=flat-square&label=RTK+downloads)](https://www.npmjs.com/package/@reduxjs/toolkit)
+[![npm downloads](https://img.shields.io/npm/dm/redux-starter-kit.svg?style=flat-square&label=RSK+downloads)](https://www.npmjs.com/package/redux-starter-kit)
 
 **The official, opinionated, batteries-included toolset for efficient Redux development**
 
diff --git a/src/createReducer.ts b/src/createReducer.ts
index cdc721b3..6c3d16c8 100644
--- a/src/createReducer.ts
+++ b/src/createReducer.ts
@@ -1,5 +1,9 @@
 import createNextState, { Draft } from 'immer'
 import { AnyAction, Action, Reducer } from 'redux'
+import {
+  executeReducerBuilderCallback,
+  ActionReducerMapBuilder
+} from './mapBuilders'
 
 /**
  * Defines a mapping from action types to corresponding action object shapes.
@@ -51,7 +55,38 @@ export type CaseReducers<S, AS extends Actions> = {
 export function createReducer<
   S,
   CR extends CaseReducers<S, any> = CaseReducers<S, any>
->(initialState: S, actionsMap: CR): Reducer<S> {
+>(initialState: S, actionsMap: CR): Reducer<S>
+/**
+ * A utility function that allows defining a reducer as a mapping from action
+ * type to *case reducer* functions that handle these action types. The
+ * reducer's initial state is passed as the first argument.
+ *
+ * The body of every case reducer is implicitly wrapped with a call to
+ * `produce()` from the [immer](https://github.com/mweststrate/immer) library.
+ * This means that rather than returning a new state object, you can also
+ * mutate the passed-in state object directly; these mutations will then be
+ * automatically and efficiently translated into copies, giving you both
+ * convenience and immutability.
+ * @param initialState The initial state to be returned by the reducer.
+ * @param builderCallback A callback that receives a *builder* object to define
+ *   case reducers via calls to `builder.addCase(actionCreatorOrType, reducer)`.
+ */
+export function createReducer<S>(
+  initialState: S,
+  builderCallback: (builder: ActionReducerMapBuilder<S>) => void
+): Reducer<S>
+
+export function createReducer<S>(
+  initialState: S,
+  mapOrBuilderCallback:
+    | CaseReducers<S, any>
+    | ((builder: ActionReducerMapBuilder<S>) => void)
+): Reducer<S> {
+  let actionsMap =
+    typeof mapOrBuilderCallback === 'function'
+      ? executeReducerBuilderCallback(mapOrBuilderCallback)
+      : mapOrBuilderCallback
+
   return function(state = initialState, action): S {
     // @ts-ignore createNextState() produces an Immutable<Draft<S>> rather
     // than an Immutable<S>, and TypeScript cannot find out how to reconcile
diff --git a/src/createSlice.ts b/src/createSlice.ts
index c7b1804e..14636226 100644
--- a/src/createSlice.ts
+++ b/src/createSlice.ts
@@ -8,6 +8,10 @@ import {
   ActionCreatorWithPreparedPayload
 } from './createAction'
 import { createReducer, CaseReducers, CaseReducer } from './createReducer'
+import {
+  ActionReducerMapBuilder,
+  executeReducerBuilderCallback
+} from './mapBuilders'
 
 /**
  * An action creator atttached to a slice.
@@ -72,8 +76,12 @@ export interface CreateSliceOptions<
    * A mapping from action types to action-type-specific *case reducer*
    * functions. These reducers should have existing action types used
    * as the keys, and action creators will _not_ be generated.
+   * Alternatively, a callback that receives a *builder* object to define
+   * case reducers via calls to `builder.addCase(actionCreatorOrType, reducer)`.
    */
-  extraReducers?: CaseReducers<NoInfer<State>, any>
+  extraReducers?:
+    | CaseReducers<NoInfer<State>, any>
+    | ((builder: ActionReducerMapBuilder<NoInfer<State>>) => void)
 }
 
 type PayloadActions<Types extends keyof any = string> = Record<
@@ -201,7 +209,13 @@ export function createSlice<
     throw new Error('`name` is a required option for createSlice')
   }
   const reducers = options.reducers || {}
-  const extraReducers = options.extraReducers || {}
+  const extraReducers =
+    typeof options.extraReducers === 'undefined'
+      ? {}
+      : typeof options.extraReducers === 'function'
+      ? executeReducerBuilderCallback(options.extraReducers)
+      : options.extraReducers
+
   const reducerNames = Object.keys(reducers)
 
   const sliceCaseReducersByName: Record<string, CaseReducer> = {}
diff --git a/src/index.ts b/src/index.ts
index 330c6858..015d0475 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,3 +8,4 @@ export * from './createReducer'
 export * from './createSlice'
 export * from './serializableStateInvariantMiddleware'
 export * from './getDefaultMiddleware'
+export { ActionReducerMapBuilder } from './mapBuilders'
diff --git a/src/mapBuilders.ts b/src/mapBuilders.ts
new file mode 100644
index 00000000..6b53d0af
--- /dev/null
+++ b/src/mapBuilders.ts
@@ -0,0 +1,57 @@
+import { Action } from 'redux'
+import { CaseReducer, CaseReducers } from './createReducer'
+
+export interface TypedActionCreator<Type extends string> {
+  (...args: any[]): Action<Type>
+  type: Type
+}
+
+/**
+ * A builder for an action <-> reducer map.
+ */
+export interface ActionReducerMapBuilder<State> {
+  /**
+   * Add a case reducer for actions created by this action creator.
+   * @param actionCreator
+   * @param reducer
+   */
+  addCase<ActionCreator extends TypedActionCreator<string>>(
+    actionCreator: ActionCreator,
+    reducer: CaseReducer<State, ReturnType<ActionCreator>>
+  ): ActionReducerMapBuilder<State>
+  /**
+   * Add a case reducer for actions with the specified type.
+   * @param type
+   * @param reducer
+   */
+  addCase<Type extends string, A extends Action<Type>>(
+    type: Type,
+    reducer: CaseReducer<State, A>
+  ): ActionReducerMapBuilder<State>
+}
+
+export function executeReducerBuilderCallback<S>(
+  builderCallback: (builder: ActionReducerMapBuilder<S>) => void
+): CaseReducers<S, any> {
+  const actionsMap: CaseReducers<S, any> = {}
+  const builder = {
+    addCase(
+      typeOrActionCreator: string | TypedActionCreator<any>,
+      reducer: CaseReducer<S>
+    ) {
+      const type =
+        typeof typeOrActionCreator === 'string'
+          ? typeOrActionCreator
+          : typeOrActionCreator.type
+      if (type in actionsMap) {
+        throw new Error(
+          'addCase cannot be called with two reducers for the same action type'
+        )
+      }
+      actionsMap[type] = reducer
+      return builder
+    }
+  }
+  builderCallback(builder)
+  return actionsMap
+}


Test patch:
diff --git a/src/createReducer.test.ts b/src/createReducer.test.ts
index ff0f6b09..a0b98bf9 100644
--- a/src/createReducer.test.ts
+++ b/src/createReducer.test.ts
@@ -1,5 +1,5 @@
 import { createReducer, CaseReducer } from './createReducer'
-import { PayloadAction } from './createAction'
+import { PayloadAction, createAction } from './createAction'
 import { Reducer } from 'redux'
 
 interface Todo {
@@ -74,6 +74,73 @@ describe('createReducer', () => {
 
     behavesLikeReducer(todosReducer)
   })
+
+  describe('alternative builder callback for actionMap', () => {
+    const increment = createAction<number, 'increment'>('increment')
+    const decrement = createAction<number, 'decrement'>('decrement')
+
+    test('can be used with ActionCreators', () => {
+      const reducer = createReducer(0, builder =>
+        builder
+          .addCase(increment, (state, action) => state + action.payload)
+          .addCase(decrement, (state, action) => state - action.payload)
+      )
+      expect(reducer(0, increment(5))).toBe(5)
+      expect(reducer(5, decrement(5))).toBe(0)
+    })
+    test('can be used with string types', () => {
+      const reducer = createReducer(0, builder =>
+        builder
+          .addCase(
+            'increment',
+            (state, action: { type: 'increment'; payload: number }) =>
+              state + action.payload
+          )
+          .addCase(
+            'decrement',
+            (state, action: { type: 'decrement'; payload: number }) =>
+              state - action.payload
+          )
+      )
+      expect(reducer(0, increment(5))).toBe(5)
+      expect(reducer(5, decrement(5))).toBe(0)
+    })
+    test('can be used with ActionCreators and string types combined', () => {
+      const reducer = createReducer(0, builder =>
+        builder
+          .addCase(increment, (state, action) => state + action.payload)
+          .addCase(
+            'decrement',
+            (state, action: { type: 'decrement'; payload: number }) =>
+              state - action.payload
+          )
+      )
+      expect(reducer(0, increment(5))).toBe(5)
+      expect(reducer(5, decrement(5))).toBe(0)
+    })
+    test('will throw if the same type is used twice', () => {
+      expect(() =>
+        createReducer(0, builder =>
+          builder
+            .addCase(increment, (state, action) => state + action.payload)
+            .addCase(increment, (state, action) => state + action.payload)
+            .addCase(decrement, (state, action) => state - action.payload)
+        )
+      ).toThrowErrorMatchingInlineSnapshot(
+        `"addCase cannot be called with two reducers for the same action type"`
+      )
+      expect(() =>
+        createReducer(0, builder =>
+          builder
+            .addCase(increment, (state, action) => state + action.payload)
+            .addCase('increment', state => state + 1)
+            .addCase(decrement, (state, action) => state - action.payload)
+        )
+      ).toThrowErrorMatchingInlineSnapshot(
+        `"addCase cannot be called with two reducers for the same action type"`
+      )
+    })
+  })
 })
 
 function behavesLikeReducer(todosReducer: TodosReducer) {
diff --git a/src/createSlice.test.ts b/src/createSlice.test.ts
index 476f184e..81092b7b 100644
--- a/src/createSlice.test.ts
+++ b/src/createSlice.test.ts
@@ -105,6 +105,57 @@ describe('createSlice', () => {
 
       expect(result).toBe(15)
     })
+
+    describe('alternative builder callback for extraReducers', () => {
+      const increment = createAction<number, 'increment'>('increment')
+
+      test('can be used with actionCreators', () => {
+        const slice = createSlice({
+          name: 'counter',
+          initialState: 0,
+          reducers: {},
+          extraReducers: builder =>
+            builder.addCase(
+              increment,
+              (state, action) => state + action.payload
+            )
+        })
+        expect(slice.reducer(0, increment(5))).toBe(5)
+      })
+
+      test('can be used with string action types', () => {
+        const slice = createSlice({
+          name: 'counter',
+          initialState: 0,
+          reducers: {},
+          extraReducers: builder =>
+            builder.addCase(
+              'increment',
+              (state, action: { type: 'increment'; payload: number }) =>
+                state + action.payload
+            )
+        })
+        expect(slice.reducer(0, increment(5))).toBe(5)
+      })
+
+      test('prevents the same action type from being specified twice', () => {
+        expect(() =>
+          createSlice({
+            name: 'counter',
+            initialState: 0,
+            reducers: {},
+            extraReducers: builder =>
+              builder
+                .addCase('increment', state => state + 1)
+                .addCase('increment', state => state + 1)
+          })
+        ).toThrowErrorMatchingInlineSnapshot(
+          `"addCase cannot be called with two reducers for the same action type"`
+        )
+      })
+
+      // for further tests, see the test of createReducer that goes way more into depth on this
+    })
   })
 
   describe('behaviour with enhanced case reducers', () => {
diff --git a/type-tests/files/createReducer.typetest.ts b/type-tests/files/createReducer.typetest.ts
index de5aef08..e3a594c4 100644
--- a/type-tests/files/createReducer.typetest.ts
+++ b/type-tests/files/createReducer.typetest.ts
@@ -1,5 +1,5 @@
 import { Reducer } from 'redux'
-import { createReducer } from '../../src'
+import { createReducer, createAction, ActionReducerMapBuilder } from '../../src'
 
 function expectType<T>(p: T) {}
 
@@ -63,3 +63,16 @@ function expectType<T>(p: T) {}
     }
   })
 }
+
+/** Test:  alternative builder callback for actionMap */
+{
+  const increment = createAction<number, 'increment'>('increment')
+
+  const reducer = createReducer(0, builder =>
+    expectType<ActionReducerMapBuilder<number>>(builder)
+  )
+
+  expectType<number>(reducer(0, increment(5)))
+  // typings:expect-error
+  expectType<string>(reducer(0, increment(5)))
+}
diff --git a/type-tests/files/createSlice.typetest.ts b/type-tests/files/createSlice.typetest.ts
index 440e4612..fd2c0ee7 100644
--- a/type-tests/files/createSlice.typetest.ts
+++ b/type-tests/files/createSlice.typetest.ts
@@ -1,5 +1,10 @@
 import { AnyAction, Reducer, Action } from 'redux'
-import { createSlice, PayloadAction, createAction } from '../../src'
+import {
+  createSlice,
+  PayloadAction,
+  createAction,
+  ActionReducerMapBuilder
+} from '../../src'
 
 function expectType<T>(t: T) {
   return t
@@ -272,3 +277,15 @@ function expectType<T>(t: T) {
     expectType<string>(x.payload)
   }
 }
+
+/** Test:  alternative builder callback for extraReducers */
+{
+  createSlice({
+    name: 'test',
+    initialState: 0,
+    reducers: {},
+    extraReducers: builder => {
+      expectType<ActionReducerMapBuilder<number>>(builder)
+    }
+  })
+}
diff --git a/type-tests/files/mapBuilders.typetest.ts b/type-tests/files/mapBuilders.typetest.ts
new file mode 100644
index 00000000..27a5b91b
--- /dev/null
+++ b/type-tests/files/mapBuilders.typetest.ts
@@ -0,0 +1,54 @@
+import { executeReducerBuilderCallback } from 'src/mapBuilders'
+import { createAction, CaseReducers } from 'src'
+
+function expectType<T>(t: T) {
+  return t
+}
+
+/** Test:  alternative builder callback for actionMap */
+{
+  const increment = createAction<number, 'increment'>('increment')
+  const decrement = createAction<number, 'decrement'>('decrement')
+
+  executeReducerBuilderCallback<number>(builder => {
+    builder.addCase(increment, (state, action) => {
+      expectType<number>(state)
+      expectType<{ type: 'increment'; payload: number }>(action)
+      // typings:expect-error
+      expectType<string>(state)
+      // typings:expect-error
+      expectType<{ type: 'increment'; payload: string }>(action)
+      // typings:expect-error
+      expectType<{ type: 'decrement'; payload: number }>(action)
+    })
+
+    builder.addCase('increment', (state, action) => {
+      expectType<number>(state)
+      expectType<{ type: 'increment' }>(action)
+      // typings:expect-error
+      expectType<{ type: 'decrement' }>(action)
+      // typings:expect-error - this cannot be inferred and has to be manually specified
+      expectType<{ type: 'increment'; payload: number }>(action)
+    })
+
+    builder.addCase(
+      increment,
+      (state, action: ReturnType<typeof increment>) => state
+    )
+    // typings:expect-error
+    builder.addCase(
+      increment,
+      (state, action: ReturnType<typeof decrement>) => state
+    )
+
+    builder.addCase(
+      'increment',
+      (state, action: ReturnType<typeof increment>) => state
+    )
+    // typings:expect-error
+    builder.addCase(
+      'decrement',
+      (state, action: ReturnType<typeof increment>) => state
+    )
+  })
+}