diff --git a/docs/faq/Actions.md b/docs/faq/Actions.md index e72a4e3ad4..8ce950fb8a 100644 --- a/docs/faq/Actions.md +++ b/docs/faq/Actions.md @@ -23,11 +23,11 @@ sidebar_label: Actions ## Actions -### Why should `type` be a string, or at least serializable? Why should my action types be constants? +### Why should `type` be a string? Why should my action types be constants? As with state, serializable actions enable several of Redux's defining features, such as time travel debugging, and recording and replaying actions. Using something like a `Symbol` for the `type` value or using `instanceof` checks for actions themselves would break that. Strings are serializable and easily self-descriptive, and so are a better choice. Note that it _is_ okay to use Symbols, Promises, or other non-serializable values in an action if the action is intended for use by middleware. Actions only need to be serializable by the time they actually reach the store and are passed to the reducers. -We can't reliably enforce serializable actions for performance reasons, so Redux only checks that every action is a plain object, and that the `type` is defined. The rest is up to you, but you might find that keeping everything serializable helps debug and reproduce issues. +We can't reliably enforce serializable actions for performance reasons, so Redux only checks that every action is a plain object, and that the `type` is a string. The rest is up to you, but you might find that keeping everything serializable helps debug and reproduce issues. Encapsulating and centralizing commonly used pieces of code is a key concept in programming. While it is certainly possible to manually create action objects everywhere, and write each `type` value by hand, defining reusable constants makes maintaining code easier. If you put constants in a separate file, you can [check your `import` statements against typos](https://www.npmjs.com/package/eslint-plugin-import) so you can't accidentally use the wrong string. diff --git a/docs/tutorials/fundamentals/part-7-standard-patterns.md b/docs/tutorials/fundamentals/part-7-standard-patterns.md index f6f2ab1336..f09b44fae1 100644 --- a/docs/tutorials/fundamentals/part-7-standard-patterns.md +++ b/docs/tutorials/fundamentals/part-7-standard-patterns.md @@ -651,7 +651,7 @@ Here's what the app looks like with that loading status enabled (to see the spin ## Flux Standard Actions -The Redux store itself does not actually care what fields you put into your action object. It only cares that `action.type` exists and has a value, and normal Redux actions always use a string for `action.type`. That means that you _could_ put any other fields into the action that you want. Maybe we could have `action.todo` for a "todo added" action, or `action.color`, and so on. +The Redux store itself does not actually care what fields you put into your action object. It only cares that `action.type` exists and is a string. That means that you _could_ put any other fields into the action that you want. Maybe we could have `action.todo` for a "todo added" action, or `action.color`, and so on. However, if every action uses different field names for its data fields, it can be hard to know ahead of time what fields you need to handle in each reducer. diff --git a/src/createStore.ts b/src/createStore.ts index f3b8ba9577..64e6bdf2fa 100644 --- a/src/createStore.ts +++ b/src/createStore.ts @@ -282,6 +282,14 @@ export function createStore< ) } + if (typeof action.type !== 'string') { + throw new Error( + `Action "type" property must be a string. Instead, the actual type was: '${kindOf( + action.type + )}'. Value was: '${action.type}' (stringified)` + ) + } + if (isDispatching) { throw new Error('Reducers may not dispatch actions.') } diff --git a/src/types/actions.ts b/src/types/actions.ts index 7446886922..9115aee62a 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -6,8 +6,7 @@ * * Actions must have a `type` field that indicates the type of action being * performed. Types can be defined as constants and imported from another - * module. It's better to use strings for `type` than Symbols because strings - * are serializable. + * module. These must be strings, as strings are serializable. * * Other than `type`, the structure of an action object is really up to you. * If you're interested, check out Flux Standard Action for recommendations on @@ -15,7 +14,7 @@ * * @template T the type of the action's `type` tag. */ -export interface Action { +export interface Action { type: T } diff --git a/test/combineReducers.spec.ts b/test/combineReducers.spec.ts index 356e72b52c..c04564577c 100644 --- a/test/combineReducers.spec.ts +++ b/test/combineReducers.spec.ts @@ -65,7 +65,7 @@ describe('Utils', () => { it('throws an error if a reducer returns undefined handling an action', () => { const reducer = combineReducers({ - counter(state: number = 0, action: Action) { + counter(state: number = 0, action: Action) { switch (action && action.type) { case 'increment': return state + 1 @@ -95,7 +95,7 @@ describe('Utils', () => { it('throws an error on first call if a reducer returns undefined initializing', () => { const reducer = combineReducers({ - counter(state: number, action: Action) { + counter(state: number, action: Action) { switch (action.type) { case 'increment': return state + 1 @@ -122,23 +122,6 @@ describe('Utils', () => { ).toThrow(/Error thrown in reducer/) }) - it('allows a symbol to be used as an action type', () => { - const increment = Symbol('INCREMENT') - - const reducer = combineReducers({ - counter(state: number = 0, action: Action) { - switch (action.type) { - case increment: - return state + 1 - default: - return state - } - } - }) - - expect(reducer({ counter: 0 }, { type: increment }).counter).toEqual(1) - }) - it('maintains referential equality if the reducers it is combining do', () => { const reducer = combineReducers({ child1(state = {}) { @@ -161,10 +144,7 @@ describe('Utils', () => { child1(state = {}) { return state }, - child2( - state: { count: number } = { count: 0 }, - action: Action - ) { + child2(state: { count: number } = { count: 0 }, action: Action) { switch (action.type) { case 'increment': return { count: state.count + 1 } @@ -185,7 +165,7 @@ describe('Utils', () => { it('throws an error on first call if a reducer attempts to handle a private action', () => { const reducer = combineReducers({ - counter(state: number, action: Action) { + counter(state: number, action: Action) { switch (action.type) { case 'increment': return state + 1 diff --git a/test/createStore.spec.ts b/test/createStore.spec.ts index 6259461d2b..cb416703b4 100644 --- a/test/createStore.spec.ts +++ b/test/createStore.spec.ts @@ -4,7 +4,8 @@ import { StoreEnhancer, Action, Store, - Reducer + Reducer, + AnyAction } from 'redux' import { vi } from 'vitest' import { @@ -567,17 +568,26 @@ describe('createStore', () => { it('throws if action type is undefined', () => { const store = createStore(reducers.todos) - expect(() => store.dispatch({ type: undefined })).toThrow( - /Actions may not have an undefined "type" property/ - ) + expect(() => + store.dispatch({ type: undefined } as unknown as AnyAction) + ).toThrow(/Actions may not have an undefined "type" property/) }) - it('does not throw if action type is falsy', () => { + it('throws if action type is not string', () => { const store = createStore(reducers.todos) - expect(() => store.dispatch({ type: false })).not.toThrow() - expect(() => store.dispatch({ type: 0 })).not.toThrow() - expect(() => store.dispatch({ type: null })).not.toThrow() - expect(() => store.dispatch({ type: '' })).not.toThrow() + expect(() => + store.dispatch({ type: false } as unknown as AnyAction) + ).toThrow(/the actual type was: 'boolean'.*Value was: 'false'/) + expect(() => store.dispatch({ type: 0 } as unknown as AnyAction)).toThrow( + /the actual type was: 'number'.*Value was: '0'/ + ) + expect(() => + store.dispatch({ type: null } as unknown as AnyAction) + ).toThrow(/the actual type was: 'null'.*Value was: 'null'/) + + expect(() => + store.dispatch({ type: '' } as unknown as AnyAction) + ).not.toThrow() }) it('accepts enhancer as the third argument', () => { diff --git a/test/typescript/actions.ts b/test/typescript/actions.ts index 1b701ce77d..0f1e37e94c 100644 --- a/test/typescript/actions.ts +++ b/test/typescript/actions.ts @@ -39,21 +39,3 @@ namespace StringLiteralTypeAction { const type: ActionType = action.type } - -namespace EnumTypeAction { - enum ActionType { - A, - B, - C - } - - interface Action extends ReduxAction { - type: ActionType - } - - const action: Action = { - type: ActionType.A - } - - const type: ActionType = action.type -} diff --git a/test/typescript/enhancers.ts b/test/typescript/enhancers.ts index 9771d5ad68..74b7dd86fd 100644 --- a/test/typescript/enhancers.ts +++ b/test/typescript/enhancers.ts @@ -159,12 +159,12 @@ function replaceReducerExtender() { test?: boolean } - const initialReducer: Reducer> = () => ({ + const initialReducer: Reducer = () => ({ someField: 'string' }) const store = createStore< PartialState, - Action, + Action, { method(): string }, ExtraState >(initialReducer, enhancer) @@ -246,10 +246,10 @@ function mhelmersonExample() { test?: boolean } - const initialReducer: Reducer> = () => ({ + const initialReducer: Reducer = () => ({ someField: 'string' }) - const store = createStore, {}, ExtraState>( + const store = createStore( initialReducer, enhancer ) @@ -276,7 +276,7 @@ function finalHelmersonExample() { foo: string } - function persistReducer, PreloadedState>( + function persistReducer( config: any, reducer: Reducer ) { @@ -300,7 +300,7 @@ function finalHelmersonExample() { persistConfig: any ): StoreEnhancer<{}, ExtraState> { return createStore => - , PreloadedState>( + ( reducer: Reducer, preloadedState?: PreloadedState | undefined ) => { @@ -323,10 +323,10 @@ function finalHelmersonExample() { test?: boolean } - const initialReducer: Reducer> = () => ({ + const initialReducer: Reducer = () => ({ someField: 'string' }) - const store = createStore, {}, ExtraState>( + const store = createStore( initialReducer, createPersistEnhancer('hi') )