Repository: reduxjs/redux-toolkit

Problem statement:
Dev check middleware to warn about action creators being dispatched 
A semi-common mistake is to dispatch an action creator without calling it first, e.g. `dispatch(actionCreator)`.

This doesn't currently raise any errors:
- In Typescript, RTK's action creators having a static `type` property mean they conform to the Action type.
- In runtime, dispatching a function is usually picked up by the thunk middleware, which would end up returning `actionCreator(dispatch, getState, extra)`.
An error would only be raised with no thunk middleware.

From a Typescript perspective, this would likely only be solveable by adding some sort of `[notAnAction)?: never` property to the base Action type, and adding a `[notAnAction]: true` property to the action creators, making it incompatible. (this also wouldn't prevent action creators without parameters being valid thunks, unless something similar was done for ThunkAction)

From a runtime perspective, this could be fairly simple: a dev-only middleware similar to the serializability and immutable middlewares looks for a unique property we assign to RTK action creators, and warns in console if it sees one.



Hints:

Patch:
diff --git a/packages/toolkit/src/actionCreatorInvariantMiddleware.ts b/packages/toolkit/src/actionCreatorInvariantMiddleware.ts
new file mode 100644
--- /dev/null
+++ b/packages/toolkit/src/actionCreatorInvariantMiddleware.ts
@@ -0,0 +1,34 @@
+import type { Middleware } from 'redux'
+import { isActionCreator as isRTKAction } from './createAction'
+
+export interface ActionCreatorInvariantMiddlewareOptions {
+  /**
+   * The function to identify whether a value is an action creator.
+   * The default checks for a function with a static type property and match method.
+   */
+  isActionCreator?: (action: unknown) => action is Function & { type?: unknown }
+}
+
+export function getMessage(type?: unknown) {
+  const splitType = type ? `${type}`.split('/') : []
+  const actionName = splitType[splitType.length - 1] || 'actionCreator'
+  return `Detected an action creator with type "${
+    type || 'unknown'
+  }" being dispatched. 
+Make sure you're calling the action creator before dispatching, i.e. \`dispatch(${actionName}())\` instead of \`dispatch(${actionName})\`. This is necessary even if the action has no payload.`
+}
+
+export function createActionCreatorInvariantMiddleware(
+  options: ActionCreatorInvariantMiddlewareOptions = {}
+): Middleware {
+  if (process.env.NODE_ENV === 'production') {
+    return () => (next) => (action) => next(action)
+  }
+  const { isActionCreator = isRTKAction } = options
+  return () => (next) => (action) => {
+    if (isActionCreator(action)) {
+      console.warn(getMessage(action.type))
+    }
+    return next(action)
+  }
+}
diff --git a/packages/toolkit/src/createAction.ts b/packages/toolkit/src/createAction.ts
--- a/packages/toolkit/src/createAction.ts
+++ b/packages/toolkit/src/createAction.ts
@@ -5,6 +5,7 @@ import type {
   IfVoid,
   IsAny,
 } from './tsHelpers'
+import { hasMatchFunction } from './tsHelpers'
 import isPlainObject from './isPlainObject'
 
 /**
@@ -293,6 +294,20 @@ export function isAction(action: unknown): action is Action<unknown> {
   return isPlainObject(action) && 'type' in action
 }
 
+/**
+ * Returns true if value is an RTK-like action creator, with a static type property and match method.
+ */
+export function isActionCreator(
+  action: unknown
+): action is BaseActionCreator<unknown, string> & Function {
+  return (
+    typeof action === 'function' &&
+    'type' in action &&
+    // hasMatchFunction only wants Matchers but I don't see the point in rewriting it
+    hasMatchFunction(action as any)
+  )
+}
+
 /**
  * Returns true if value is an action with a string type and valid Flux Standard Action keys.
  */
diff --git a/packages/toolkit/src/getDefaultMiddleware.ts b/packages/toolkit/src/getDefaultMiddleware.ts
--- a/packages/toolkit/src/getDefaultMiddleware.ts
+++ b/packages/toolkit/src/getDefaultMiddleware.ts
@@ -1,6 +1,8 @@
 import type { Middleware, AnyAction } from 'redux'
 import type { ThunkMiddleware } from 'redux-thunk'
 import thunkMiddleware from 'redux-thunk'
+import type { ActionCreatorInvariantMiddlewareOptions } from './actionCreatorInvariantMiddleware'
+import { createActionCreatorInvariantMiddleware } from './actionCreatorInvariantMiddleware'
 import type { ImmutableStateInvariantMiddlewareOptions } from './immutableStateInvariantMiddleware'
 /* PROD_START_REMOVE_UMD */
 import { createImmutableStateInvariantMiddleware } from './immutableStateInvariantMiddleware'
@@ -23,6 +25,7 @@ interface GetDefaultMiddlewareOptions {
   thunk?: boolean | ThunkOptions
   immutableCheck?: boolean | ImmutableStateInvariantMiddlewareOptions
   serializableCheck?: boolean | SerializableStateInvariantMiddlewareOptions
+  actionCreatorCheck?: boolean | ActionCreatorInvariantMiddlewareOptions
 }
 
 export type ThunkMiddlewareFor<
@@ -41,6 +44,7 @@ export type CurriedGetDefaultMiddleware<S = any> = <
     thunk: true
     immutableCheck: true
     serializableCheck: true
+    actionCreatorCheck: true
   }
 >(
   options?: O
@@ -72,6 +76,7 @@ export function getDefaultMiddleware<
     thunk: true
     immutableCheck: true
     serializableCheck: true
+    actionCreatorCheck: true
   }
 >(
   options: O = {} as O
@@ -80,6 +85,7 @@ export function getDefaultMiddleware<
     thunk = true,
     immutableCheck = true,
     serializableCheck = true,
+    actionCreatorCheck = true,
   } = options
 
   let middlewareArray = new MiddlewareArray<Middleware[]>()
@@ -120,6 +126,17 @@ export function getDefaultMiddleware<
         createSerializableStateInvariantMiddleware(serializableOptions)
       )
     }
+    if (actionCreatorCheck) {
+      let actionCreatorOptions: ActionCreatorInvariantMiddlewareOptions = {}
+
+      if (!isBoolean(actionCreatorCheck)) {
+        actionCreatorOptions = actionCreatorCheck
+      }
+
+      middlewareArray.unshift(
+        createActionCreatorInvariantMiddleware(actionCreatorOptions)
+      )
+    }
   }
 
   return middlewareArray as any
diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts
--- a/packages/toolkit/src/index.ts
+++ b/packages/toolkit/src/index.ts
@@ -40,6 +40,7 @@ export {
   createAction,
   getType,
   isAction,
+  isActionCreator,
   isFSA as isFluxStandardAction,
 } from './createAction'
 export type {
@@ -78,6 +79,8 @@ export type {
   CaseReducerWithPrepare,
   SliceActionCreator,
 } from './createSlice'
+export type { ActionCreatorInvariantMiddlewareOptions } from './actionCreatorInvariantMiddleware'
+export { createActionCreatorInvariantMiddleware } from './actionCreatorInvariantMiddleware'
 export {
   // js
   createImmutableStateInvariantMiddleware,


Test patch:
diff --git a/packages/toolkit/src/tests/actionCreatorInvariantMiddleware.test.ts b/packages/toolkit/src/tests/actionCreatorInvariantMiddleware.test.ts
new file mode 100644
--- /dev/null
+++ b/packages/toolkit/src/tests/actionCreatorInvariantMiddleware.test.ts
@@ -0,0 +1,64 @@
+import type { ActionCreatorInvariantMiddlewareOptions } from '@internal/actionCreatorInvariantMiddleware'
+import { getMessage } from '@internal/actionCreatorInvariantMiddleware'
+import { createActionCreatorInvariantMiddleware } from '@internal/actionCreatorInvariantMiddleware'
+import type { Dispatch, MiddlewareAPI } from '@reduxjs/toolkit'
+import { createAction } from '@reduxjs/toolkit'
+
+describe('createActionCreatorInvariantMiddleware', () => {
+  const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
+
+  afterEach(() => {
+    consoleSpy.mockClear()
+  })
+  afterAll(() => {
+    consoleSpy.mockRestore()
+  })
+
+  const dummyAction = createAction('aSlice/anAction')
+
+  it('sends the action through the middleware chain', () => {
+    const next: Dispatch = (action) => ({
+      ...action,
+      returned: true,
+    })
+    const dispatch = createActionCreatorInvariantMiddleware()(
+      {} as MiddlewareAPI
+    )(next)
+
+    expect(dispatch(dummyAction())).toEqual({
+      ...dummyAction(),
+      returned: true,
+    })
+  })
+
+  const makeActionTester = (
+    options?: ActionCreatorInvariantMiddlewareOptions
+  ) =>
+    createActionCreatorInvariantMiddleware(options)({} as MiddlewareAPI)(
+      (action) => action
+    )
+
+  it('logs a warning to console if an action creator is mistakenly dispatched', () => {
+    const testAction = makeActionTester()
+
+    testAction(dummyAction())
+
+    expect(consoleSpy).not.toHaveBeenCalled()
+
+    testAction(dummyAction)
+
+    expect(consoleSpy).toHaveBeenLastCalledWith(getMessage(dummyAction.type))
+  })
+
+  it('allows passing a custom predicate', () => {
+    let predicateCalled = false
+    const testAction = makeActionTester({
+      isActionCreator(action): action is Function {
+        predicateCalled = true
+        return false
+      },
+    })
+    testAction(dummyAction())
+    expect(predicateCalled).toBe(true)
+  })
+})
diff --git a/packages/toolkit/src/tests/configureStore.test.ts b/packages/toolkit/src/tests/configureStore.test.ts
--- a/packages/toolkit/src/tests/configureStore.test.ts
+++ b/packages/toolkit/src/tests/configureStore.test.ts
@@ -75,9 +75,10 @@ describe('configureStore', () => {
         Object
       )
       expect(redux.applyMiddleware).toHaveBeenCalledWith(
-        expect.any(Function), // thunk
         expect.any(Function), // immutableCheck
-        expect.any(Function) // serializableCheck
+        expect.any(Function), // thunk
+        expect.any(Function), // serializableCheck
+        expect.any(Function) // actionCreatorCheck
       )
       expect(devtools.composeWithDevTools).toHaveBeenCalled() // @remap-prod-remove-line-line
       expect(redux.createStore).toHaveBeenCalledWith(
diff --git a/packages/toolkit/src/tests/getDefaultMiddleware.test.ts b/packages/toolkit/src/tests/getDefaultMiddleware.test.ts
--- a/packages/toolkit/src/tests/getDefaultMiddleware.test.ts
+++ b/packages/toolkit/src/tests/getDefaultMiddleware.test.ts
@@ -15,6 +15,7 @@ import thunk from 'redux-thunk'
 import type { ThunkMiddleware } from 'redux-thunk'
 
 import { expectType } from './helpers'
+import { BaseActionCreator } from '@internal/createAction'
 
 describe('getDefaultMiddleware', () => {
   const ORIGINAL_NODE_ENV = process.env.NODE_ENV
@@ -35,31 +36,37 @@ describe('getDefaultMiddleware', () => {
     expect(middleware.length).toBeGreaterThan(1)
   })
 
+  const defaultMiddleware = getDefaultMiddleware()
+
   it('removes the thunk middleware if disabled', () => {
     const middleware = getDefaultMiddleware({ thunk: false })
     // @ts-ignore
     expect(middleware.includes(thunk)).toBe(false)
-    expect(middleware.length).toBe(2)
+    expect(middleware.length).toBe(defaultMiddleware.length - 1)
   })
 
   it('removes the immutable middleware if disabled', () => {
-    const defaultMiddleware = getDefaultMiddleware()
     const middleware = getDefaultMiddleware({ immutableCheck: false })
     expect(middleware.length).toBe(defaultMiddleware.length - 1)
   })
 
   it('removes the serializable middleware if disabled', () => {
-    const defaultMiddleware = getDefaultMiddleware()
     const middleware = getDefaultMiddleware({ serializableCheck: false })
     expect(middleware.length).toBe(defaultMiddleware.length - 1)
   })
 
+  it('removes the action creator middleware if disabled', () => {
+    const middleware = getDefaultMiddleware({ actionCreatorCheck: false })
+    expect(middleware.length).toBe(defaultMiddleware.length - 1)
+  })
+
   it('allows passing options to thunk', () => {
     const extraArgument = 42 as const
     const middleware = getDefaultMiddleware({
       thunk: { extraArgument },
       immutableCheck: false,
       serializableCheck: false,
+      actionCreatorCheck: false,
     })
 
     const m2 = getDefaultMiddleware({
@@ -129,6 +136,7 @@ describe('getDefaultMiddleware', () => {
         },
       },
       serializableCheck: false,
+      actionCreatorCheck: false,
     })
 
     const reducer = () => ({})
@@ -153,6 +161,7 @@ describe('getDefaultMiddleware', () => {
           return true
         },
       },
+      actionCreatorCheck: false,
     })
 
     const reducer = () => ({})
@@ -168,6 +177,33 @@ describe('getDefaultMiddleware', () => {
   })
 })
 
+it('allows passing options to actionCreatorCheck', () => {
+  let actionCreatorCheckWasCalled = false
+
+  const middleware = getDefaultMiddleware({
+    thunk: false,
+    immutableCheck: false,
+    serializableCheck: false,
+    actionCreatorCheck: {
+      isActionCreator: (action: unknown): action is Function => {
+        actionCreatorCheckWasCalled = true
+        return false
+      },
+    },
+  })
+
+  const reducer = () => ({})
+
+  const store = configureStore({
+    reducer,
+    middleware,
+  })
+
+  store.dispatch({ type: 'TEST_ACTION' })
+
+  expect(actionCreatorCheckWasCalled).toBe(true)
+})
+
 describe('MiddlewareArray functionality', () => {
   const middleware1: Middleware = () => (next) => (action) => next(action)
   const middleware2: Middleware = () => (next) => (action) => next(action)