@depup/reduxjs__toolkit 2.12.0-depup.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/changes.json +18 -0
- package/dist/cjs/index.js +6 -0
- package/dist/cjs/redux-toolkit.development.cjs +2406 -0
- package/dist/cjs/redux-toolkit.development.cjs.map +1 -0
- package/dist/cjs/redux-toolkit.production.min.cjs +3 -0
- package/dist/cjs/redux-toolkit.production.min.cjs.map +1 -0
- package/dist/index.d.mts +2665 -0
- package/dist/index.d.ts +2665 -0
- package/dist/query/cjs/index.js +6 -0
- package/dist/query/cjs/rtk-query.development.cjs +3092 -0
- package/dist/query/cjs/rtk-query.development.cjs.map +1 -0
- package/dist/query/cjs/rtk-query.production.min.cjs +2 -0
- package/dist/query/cjs/rtk-query.production.min.cjs.map +1 -0
- package/dist/query/index.d.mts +3066 -0
- package/dist/query/index.d.ts +3066 -0
- package/dist/query/react/cjs/index.js +6 -0
- package/dist/query/react/cjs/rtk-query-react.development.cjs +748 -0
- package/dist/query/react/cjs/rtk-query-react.development.cjs.map +1 -0
- package/dist/query/react/cjs/rtk-query-react.production.min.cjs +2 -0
- package/dist/query/react/cjs/rtk-query-react.production.min.cjs.map +1 -0
- package/dist/query/react/index.d.mts +1009 -0
- package/dist/query/react/index.d.ts +1009 -0
- package/dist/query/react/rtk-query-react.browser.mjs +2 -0
- package/dist/query/react/rtk-query-react.browser.mjs.map +1 -0
- package/dist/query/react/rtk-query-react.legacy-esm.js +740 -0
- package/dist/query/react/rtk-query-react.legacy-esm.js.map +1 -0
- package/dist/query/react/rtk-query-react.modern.mjs +705 -0
- package/dist/query/react/rtk-query-react.modern.mjs.map +1 -0
- package/dist/query/rtk-query.browser.mjs +2 -0
- package/dist/query/rtk-query.browser.mjs.map +1 -0
- package/dist/query/rtk-query.legacy-esm.js +3117 -0
- package/dist/query/rtk-query.legacy-esm.js.map +1 -0
- package/dist/query/rtk-query.modern.mjs +3052 -0
- package/dist/query/rtk-query.modern.mjs.map +1 -0
- package/dist/react/cjs/index.js +6 -0
- package/dist/react/cjs/redux-toolkit-react.development.cjs +55 -0
- package/dist/react/cjs/redux-toolkit-react.development.cjs.map +1 -0
- package/dist/react/cjs/redux-toolkit-react.production.min.cjs +2 -0
- package/dist/react/cjs/redux-toolkit-react.production.min.cjs.map +1 -0
- package/dist/react/index.d.mts +22 -0
- package/dist/react/index.d.ts +22 -0
- package/dist/react/redux-toolkit-react.browser.mjs +2 -0
- package/dist/react/redux-toolkit-react.browser.mjs.map +1 -0
- package/dist/react/redux-toolkit-react.legacy-esm.js +47 -0
- package/dist/react/redux-toolkit-react.legacy-esm.js.map +1 -0
- package/dist/react/redux-toolkit-react.modern.mjs +28 -0
- package/dist/react/redux-toolkit-react.modern.mjs.map +1 -0
- package/dist/redux-toolkit.browser.mjs +3 -0
- package/dist/redux-toolkit.browser.mjs.map +1 -0
- package/dist/redux-toolkit.legacy-esm.js +2351 -0
- package/dist/redux-toolkit.legacy-esm.js.map +1 -0
- package/dist/redux-toolkit.modern.mjs +2330 -0
- package/dist/redux-toolkit.modern.mjs.map +1 -0
- package/dist/uncheckedindexed.ts +16 -0
- package/package.json +310 -0
- package/query/package.json +13 -0
- package/query/react/package.json +13 -0
- package/react/package.json +13 -0
- package/skills/build-modern-redux-apps/modern-redux/SKILL.md +304 -0
- package/skills/build-modern-redux-apps/modern-redux/references/store-lifetime.md +53 -0
- package/skills/build-modern-redux-apps/redux-dataflow/SKILL.md +264 -0
- package/skills/evolve-and-diagnose-redux-apps/debug-redux-toolkit-apps/SKILL.md +269 -0
- package/skills/evolve-and-diagnose-redux-apps/migrate-to-modern-redux/SKILL.md +226 -0
- package/skills/manage-server-data/adopt-rtk-query/SKILL.md +385 -0
- package/skills/manage-server-data/adopt-rtk-query/references/endpoint-lifecycle.md +36 -0
- package/skills/model-redux-state/build-slices-and-selectors/SKILL.md +364 -0
- package/skills/model-redux-state/build-slices-and-selectors/references/slice-patterns.md +59 -0
- package/skills/model-redux-state/design-state-ownership/SKILL.md +322 -0
- package/skills/model-redux-state/design-state-ownership/references/state-ownership.md +28 -0
- package/skills/orchestrate-side-effects/handle-side-effects/SKILL.md +271 -0
- package/skills/orchestrate-side-effects/handle-side-effects/references/listener-workflows.md +34 -0
- package/src/actionCreatorInvariantMiddleware.ts +34 -0
- package/src/autoBatchEnhancer.ts +146 -0
- package/src/combineSlices.ts +487 -0
- package/src/configureStore.ts +248 -0
- package/src/createAction.ts +324 -0
- package/src/createAsyncThunk.ts +791 -0
- package/src/createDraftSafeSelector.ts +30 -0
- package/src/createReducer.ts +217 -0
- package/src/createSlice.ts +1079 -0
- package/src/devtoolsExtension.ts +241 -0
- package/src/dynamicMiddleware/index.ts +93 -0
- package/src/dynamicMiddleware/react/index.ts +101 -0
- package/src/dynamicMiddleware/types.ts +82 -0
- package/src/entities/create_adapter.ts +47 -0
- package/src/entities/entity_state.ts +38 -0
- package/src/entities/index.ts +8 -0
- package/src/entities/models.ts +198 -0
- package/src/entities/sorted_state_adapter.ts +266 -0
- package/src/entities/state_adapter.ts +58 -0
- package/src/entities/state_selectors.ts +73 -0
- package/src/entities/unsorted_state_adapter.ts +204 -0
- package/src/entities/utils.ts +68 -0
- package/src/formatProdErrorMessage.ts +13 -0
- package/src/getDefaultEnhancers.ts +31 -0
- package/src/getDefaultMiddleware.ts +113 -0
- package/src/immerImports.ts +7 -0
- package/src/immutableStateInvariantMiddleware.ts +274 -0
- package/src/index.ts +213 -0
- package/src/listenerMiddleware/exceptions.ts +20 -0
- package/src/listenerMiddleware/index.ts +562 -0
- package/src/listenerMiddleware/task.ts +100 -0
- package/src/listenerMiddleware/types.ts +886 -0
- package/src/listenerMiddleware/utils.ts +30 -0
- package/src/mapBuilders.ts +298 -0
- package/src/matchers.ts +365 -0
- package/src/nanoid.ts +20 -0
- package/src/query/HandledError.ts +6 -0
- package/src/query/apiTypes.ts +120 -0
- package/src/query/baseQueryTypes.ts +101 -0
- package/src/query/core/apiState.ts +377 -0
- package/src/query/core/buildInitiate.ts +596 -0
- package/src/query/core/buildMiddleware/batchActions.ts +207 -0
- package/src/query/core/buildMiddleware/cacheCollection.ts +204 -0
- package/src/query/core/buildMiddleware/cacheLifecycle.ts +379 -0
- package/src/query/core/buildMiddleware/devMiddleware.ts +34 -0
- package/src/query/core/buildMiddleware/index.ts +162 -0
- package/src/query/core/buildMiddleware/invalidationByTags.ts +139 -0
- package/src/query/core/buildMiddleware/polling.ts +187 -0
- package/src/query/core/buildMiddleware/queryLifecycle.ts +508 -0
- package/src/query/core/buildMiddleware/types.ts +173 -0
- package/src/query/core/buildMiddleware/windowEventHandling.ts +64 -0
- package/src/query/core/buildSelectors.ts +413 -0
- package/src/query/core/buildSlice.ts +735 -0
- package/src/query/core/buildThunks.ts +1124 -0
- package/src/query/core/index.ts +58 -0
- package/src/query/core/module.ts +727 -0
- package/src/query/core/rtkImports.ts +24 -0
- package/src/query/core/setupListeners.ts +118 -0
- package/src/query/createApi.ts +506 -0
- package/src/query/defaultSerializeQueryArgs.ts +52 -0
- package/src/query/endpointDefinitions.ts +1717 -0
- package/src/query/fakeBaseQuery.ts +21 -0
- package/src/query/fetchBaseQuery.ts +385 -0
- package/src/query/index.ts +105 -0
- package/src/query/react/ApiProvider.tsx +69 -0
- package/src/query/react/buildHooks.ts +2297 -0
- package/src/query/react/constants.ts +2 -0
- package/src/query/react/index.ts +45 -0
- package/src/query/react/module.ts +275 -0
- package/src/query/react/namedHooks.ts +59 -0
- package/src/query/react/reactImports.ts +10 -0
- package/src/query/react/reactReduxImports.ts +1 -0
- package/src/query/react/rtkqImports.ts +8 -0
- package/src/query/react/useSerializedStableValue.ts +17 -0
- package/src/query/react/useShallowStableValue.ts +13 -0
- package/src/query/retry.ts +233 -0
- package/src/query/standardSchema.ts +35 -0
- package/src/query/tsHelpers.ts +48 -0
- package/src/query/utils/capitalize.ts +3 -0
- package/src/query/utils/copyWithStructuralSharing.ts +27 -0
- package/src/query/utils/countObjectKeys.ts +14 -0
- package/src/query/utils/filterMap.ts +27 -0
- package/src/query/utils/getCurrent.ts +6 -0
- package/src/query/utils/getOrInsert.ts +41 -0
- package/src/query/utils/immerImports.ts +9 -0
- package/src/query/utils/index.ts +11 -0
- package/src/query/utils/isAbsoluteUrl.ts +9 -0
- package/src/query/utils/isDocumentVisible.ts +12 -0
- package/src/query/utils/isNotNullish.ts +7 -0
- package/src/query/utils/isOnline.ts +12 -0
- package/src/query/utils/isValidUrl.ts +9 -0
- package/src/query/utils/joinUrls.ts +26 -0
- package/src/query/utils/signals.ts +33 -0
- package/src/react/index.ts +7 -0
- package/src/reduxImports.ts +8 -0
- package/src/reselectImports.ts +1 -0
- package/src/serializableStateInvariantMiddleware.ts +285 -0
- package/src/tsHelpers.ts +214 -0
- package/src/uncheckedindexed.ts +16 -0
- package/src/utils.ts +125 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: model-redux-state/design-state-ownership
|
|
3
|
+
description: >
|
|
4
|
+
Use this when deciding whether data belongs in Redux, component state, router
|
|
5
|
+
state, or another external source. Covers state ownership, authority
|
|
6
|
+
boundaries, slice sizing, and when to move or split data as the app evolves.
|
|
7
|
+
type: core
|
|
8
|
+
library: "@reduxjs/toolkit"
|
|
9
|
+
library_version: "2.11.2"
|
|
10
|
+
sources:
|
|
11
|
+
- "reduxjs/redux:docs/style-guide/style-guide.md"
|
|
12
|
+
- "reduxjs/redux:docs/tutorials/essentials/part-2-app-structure.md"
|
|
13
|
+
- "reduxjs/redux:docs/tutorials/essentials/part-4-using-data.md"
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Design State Ownership
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { useState } from 'react'
|
|
22
|
+
import { createSlice } from '@reduxjs/toolkit'
|
|
23
|
+
import { useAppDispatch } from '../../app/hooks'
|
|
24
|
+
|
|
25
|
+
const postsSlice = createSlice({
|
|
26
|
+
name: 'posts',
|
|
27
|
+
initialState: [] as { id: string; title: string; content: string }[],
|
|
28
|
+
reducers: {
|
|
29
|
+
postAdded(
|
|
30
|
+
state,
|
|
31
|
+
action: { payload: { id: string; title: string; content: string } },
|
|
32
|
+
) {
|
|
33
|
+
state.push(action.payload)
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const { postAdded } = postsSlice.actions
|
|
39
|
+
|
|
40
|
+
export function AddPostForm() {
|
|
41
|
+
const [title, setTitle] = useState('')
|
|
42
|
+
const [content, setContent] = useState('')
|
|
43
|
+
const dispatch = useAppDispatch()
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<form
|
|
47
|
+
onSubmit={(event) => {
|
|
48
|
+
event.preventDefault()
|
|
49
|
+
dispatch(postAdded({ id: 'p1', title, content }))
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
<input value={title} onChange={(event) => setTitle(event.target.value)} />
|
|
53
|
+
<textarea
|
|
54
|
+
value={content}
|
|
55
|
+
onChange={(event) => setContent(event.target.value)}
|
|
56
|
+
/>
|
|
57
|
+
<button type="submit">Save</button>
|
|
58
|
+
</form>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Core Patterns
|
|
64
|
+
|
|
65
|
+
### Keep editable form state local until the user commits it
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
import { useState } from 'react'
|
|
69
|
+
import { useAppDispatch } from '../../app/hooks'
|
|
70
|
+
import { profileSaved } from './profileSlice'
|
|
71
|
+
|
|
72
|
+
export function ProfileForm() {
|
|
73
|
+
const [displayName, setDisplayName] = useState('Lenz')
|
|
74
|
+
const dispatch = useAppDispatch()
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<form
|
|
78
|
+
onSubmit={(event) => {
|
|
79
|
+
event.preventDefault()
|
|
80
|
+
dispatch(profileSaved({ displayName }))
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
<input
|
|
84
|
+
value={displayName}
|
|
85
|
+
onChange={(event) => setDisplayName(event.target.value)}
|
|
86
|
+
/>
|
|
87
|
+
<button type="submit">Save</button>
|
|
88
|
+
</form>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Prefer Redux for shared, durable app state, not every keystroke.
|
|
94
|
+
|
|
95
|
+
### Keep URL state with the router and combine it at the edge
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
import { createSelector } from '@reduxjs/toolkit'
|
|
99
|
+
import { useSearchParams } from 'react-router-dom'
|
|
100
|
+
import { useAppSelector } from '../../app/hooks'
|
|
101
|
+
|
|
102
|
+
type RootState = {
|
|
103
|
+
posts: {
|
|
104
|
+
items: { id: string; title: string; published: boolean }[]
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const selectPosts = (state: RootState) => state.posts.items
|
|
109
|
+
|
|
110
|
+
const selectVisiblePosts = createSelector(
|
|
111
|
+
[selectPosts, (_state: RootState, filter: string) => filter],
|
|
112
|
+
(posts, filter) =>
|
|
113
|
+
filter === 'published'
|
|
114
|
+
? posts.filter((post) => post.published)
|
|
115
|
+
: posts,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
export function PostsList() {
|
|
119
|
+
const [searchParams] = useSearchParams()
|
|
120
|
+
const filter = searchParams.get('filter') ?? 'all'
|
|
121
|
+
const posts = useAppSelector((state) => selectVisiblePosts(state, filter))
|
|
122
|
+
|
|
123
|
+
return <div>{posts.length}</div>
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
If the router already owns a piece of state, pass it into selectors or combine it in the component instead of syncing it into Redux.
|
|
128
|
+
|
|
129
|
+
### Re-size slices when access patterns change
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { combineReducers, createSlice } from '@reduxjs/toolkit'
|
|
133
|
+
|
|
134
|
+
const authSlice = createSlice({
|
|
135
|
+
name: 'auth',
|
|
136
|
+
initialState: { userId: null as string | null },
|
|
137
|
+
reducers: {},
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const postsSlice = createSlice({
|
|
141
|
+
name: 'posts',
|
|
142
|
+
initialState: { items: [] as { id: string; title: string }[] },
|
|
143
|
+
reducers: {},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
export const rootReducer = combineReducers({
|
|
147
|
+
auth: authSlice.reducer,
|
|
148
|
+
posts: postsSlice.reducer,
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Revisit slice size over time; unrelated data should split apart, and data constantly stitched together in every component may belong closer together.
|
|
153
|
+
|
|
154
|
+
## Common Mistakes
|
|
155
|
+
|
|
156
|
+
### MEDIUM Putting form editing state in Redux
|
|
157
|
+
|
|
158
|
+
Wrong:
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
import { useAppSelector } from '../../app/hooks'
|
|
162
|
+
|
|
163
|
+
const selectDraftTitle = (state: { draft: { title: string } }) => state.draft.title
|
|
164
|
+
|
|
165
|
+
const title = useAppSelector(selectDraftTitle)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Correct:
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
const [title, setTitle] = useState('')
|
|
172
|
+
|
|
173
|
+
<input value={title} onChange={(event) => setTitle(event.target.value)} />
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Per-keystroke dispatching adds global complexity for data that usually lives in one component tree.
|
|
177
|
+
|
|
178
|
+
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
|
179
|
+
|
|
180
|
+
### HIGH Synchronizing router or URL state into Redux
|
|
181
|
+
|
|
182
|
+
Wrong:
|
|
183
|
+
|
|
184
|
+
```tsx
|
|
185
|
+
import { useEffect } from 'react'
|
|
186
|
+
import { useSearchParams } from 'react-router-dom'
|
|
187
|
+
import { useAppDispatch } from '../../app/hooks'
|
|
188
|
+
|
|
189
|
+
function PostsPage() {
|
|
190
|
+
const [searchParams] = useSearchParams()
|
|
191
|
+
const dispatch = useAppDispatch()
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
dispatch(filterChanged(searchParams.get('filter') ?? 'all'))
|
|
195
|
+
}, [dispatch, searchParams])
|
|
196
|
+
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Correct:
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
const filter = searchParams.get('filter') ?? 'all'
|
|
205
|
+
const posts = useAppSelector((state) => selectVisiblePosts(state, filter))
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
URL state already has an authoritative owner; duplicating it into Redux creates two sources of truth.
|
|
209
|
+
|
|
210
|
+
Source: maintainer interview
|
|
211
|
+
|
|
212
|
+
### HIGH Naming state after components
|
|
213
|
+
|
|
214
|
+
Wrong:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
import { combineReducers } from '@reduxjs/toolkit'
|
|
218
|
+
|
|
219
|
+
const loginReducer = (state = { open: false }) => state
|
|
220
|
+
const postsReducer = (state = [] as { id: string; title: string }[]) => state
|
|
221
|
+
|
|
222
|
+
const rootReducer = combineReducers({
|
|
223
|
+
loginScreen: loginReducer,
|
|
224
|
+
postsList: postsReducer,
|
|
225
|
+
})
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Correct:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { combineReducers } from '@reduxjs/toolkit'
|
|
232
|
+
|
|
233
|
+
const authReducer = (state = { userId: null as string | null }) => state
|
|
234
|
+
const postsReducer = (state = [] as { id: string; title: string }[]) => state
|
|
235
|
+
|
|
236
|
+
const rootReducer = combineReducers({
|
|
237
|
+
auth: authReducer,
|
|
238
|
+
posts: postsReducer,
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Store keys should describe data or domain concepts, not the current component tree.
|
|
243
|
+
|
|
244
|
+
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
|
245
|
+
|
|
246
|
+
### MEDIUM Letting slice boundaries fossilize
|
|
247
|
+
|
|
248
|
+
Wrong:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
import { createSlice } from '@reduxjs/toolkit'
|
|
252
|
+
|
|
253
|
+
type Post = { id: string; title: string }
|
|
254
|
+
type AppNotification = { id: string; message: string }
|
|
255
|
+
|
|
256
|
+
const appSlice = createSlice({
|
|
257
|
+
name: 'app',
|
|
258
|
+
initialState: {
|
|
259
|
+
auth: { userId: null as string | null },
|
|
260
|
+
posts: [] as Post[],
|
|
261
|
+
notifications: [] as AppNotification[],
|
|
262
|
+
},
|
|
263
|
+
reducers: {},
|
|
264
|
+
})
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Correct:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
import { createSlice } from '@reduxjs/toolkit'
|
|
271
|
+
|
|
272
|
+
type Post = { id: string; title: string }
|
|
273
|
+
|
|
274
|
+
const authSlice = createSlice({
|
|
275
|
+
name: 'auth',
|
|
276
|
+
initialState: { userId: null as string | null },
|
|
277
|
+
reducers: {},
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const postsSlice = createSlice({
|
|
281
|
+
name: 'posts',
|
|
282
|
+
initialState: [] as Post[],
|
|
283
|
+
reducers: {},
|
|
284
|
+
})
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
When unrelated data is welded together, every change point gets noisier; split or merge slices as actual access patterns demand.
|
|
288
|
+
|
|
289
|
+
Source: maintainer interview
|
|
290
|
+
|
|
291
|
+
### HIGH Blindly spreading payloads into state
|
|
292
|
+
|
|
293
|
+
Wrong:
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
const state = { id: '1', name: 'Lenz' }
|
|
297
|
+
const action = { payload: { id: '2', name: 'Mark', ignored: true } }
|
|
298
|
+
|
|
299
|
+
userLoggedIn(state, action) {
|
|
300
|
+
return { ...state, ...action.payload }
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Correct:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
const state = { id: '1', name: 'Lenz' }
|
|
308
|
+
const action = { payload: { id: '2', name: 'Mark', ignored: true } }
|
|
309
|
+
|
|
310
|
+
userLoggedIn(state, action) {
|
|
311
|
+
state.id = action.payload.id
|
|
312
|
+
state.name = action.payload.name
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Reducers should own the slice shape instead of treating payloads as trusted state patches.
|
|
317
|
+
|
|
318
|
+
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
|
319
|
+
|
|
320
|
+
## References
|
|
321
|
+
|
|
322
|
+
- [State ownership heuristics](references/state-ownership.md)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# State Ownership Heuristics
|
|
2
|
+
|
|
3
|
+
## Choose the owner, then the tool
|
|
4
|
+
|
|
5
|
+
| Kind of state | Default owner | Typical tool |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| Editable form fields | Component | `useState` |
|
|
8
|
+
| Shared mutable app data | Redux | Slice state |
|
|
9
|
+
| Server cache | RTK Query | `createApi` |
|
|
10
|
+
| URL, pathname, search params | Router | Router APIs plus selector inputs |
|
|
11
|
+
| Browser-only authority like `localStorage` | External source | Read at boundaries, then dispatch events |
|
|
12
|
+
|
|
13
|
+
## Good reasons to move data into Redux
|
|
14
|
+
|
|
15
|
+
- Multiple distant parts of the UI need the same mutable data.
|
|
16
|
+
- You need time-travel debugging or a stable action history.
|
|
17
|
+
- The reducer should own transitions because they mix old store state with new inputs.
|
|
18
|
+
|
|
19
|
+
## Reasons to keep data out of Redux
|
|
20
|
+
|
|
21
|
+
- Another system already owns it, such as the router.
|
|
22
|
+
- It only matters during editing inside one component tree.
|
|
23
|
+
- It is server cache and RTK Query fits the use case better.
|
|
24
|
+
|
|
25
|
+
## Re-evaluate slice size
|
|
26
|
+
|
|
27
|
+
- If data is constantly stitched together outside reducers, it may belong closer together.
|
|
28
|
+
- If unrelated updates keep touching the same slice, split the slice by domain ownership.
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: orchestrate-side-effects/handle-side-effects
|
|
3
|
+
description: >
|
|
4
|
+
Use this when choosing between RTK Query, createAsyncThunk, handwritten
|
|
5
|
+
thunks, and createListenerMiddleware. Covers imperative versus reactive
|
|
6
|
+
workflows, listener middleware setup, and keeping side effects out of
|
|
7
|
+
reducers and UI components.
|
|
8
|
+
type: core
|
|
9
|
+
library: "@reduxjs/toolkit"
|
|
10
|
+
library_version: "2.11.2"
|
|
11
|
+
requires:
|
|
12
|
+
- build-modern-redux-apps/redux-dataflow
|
|
13
|
+
sources:
|
|
14
|
+
- "reduxjs/redux-toolkit:docs/api/createAsyncThunk.mdx"
|
|
15
|
+
- "reduxjs/redux-toolkit:docs/api/createListenerMiddleware.mdx"
|
|
16
|
+
- "reduxjs/redux:docs/style-guide/style-guide.md"
|
|
17
|
+
- "reduxjs/redux:docs/tutorials/essentials/part-5-async-logic.md"
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# Handle Side Effects
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import {
|
|
26
|
+
configureStore,
|
|
27
|
+
createListenerMiddleware,
|
|
28
|
+
createSlice,
|
|
29
|
+
} from '@reduxjs/toolkit'
|
|
30
|
+
|
|
31
|
+
const docsSlice = createSlice({
|
|
32
|
+
name: 'docs',
|
|
33
|
+
initialState: { status: 'idle' as 'idle' | 'saved' },
|
|
34
|
+
reducers: {
|
|
35
|
+
saveStarted(state) {
|
|
36
|
+
state.status = 'idle'
|
|
37
|
+
},
|
|
38
|
+
saveFinished(state) {
|
|
39
|
+
state.status = 'saved'
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const listenerMiddleware = createListenerMiddleware()
|
|
45
|
+
|
|
46
|
+
export const store = configureStore({
|
|
47
|
+
reducer: {
|
|
48
|
+
docs: docsSlice.reducer,
|
|
49
|
+
},
|
|
50
|
+
middleware: (getDefaultMiddleware) =>
|
|
51
|
+
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
export type RootState = ReturnType<typeof store.getState>
|
|
55
|
+
export type AppDispatch = typeof store.dispatch
|
|
56
|
+
|
|
57
|
+
export const startAppListening =
|
|
58
|
+
listenerMiddleware.startListening.withTypes<RootState, AppDispatch>()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Core Patterns
|
|
62
|
+
|
|
63
|
+
### Use RTK Query for server cache by default
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
|
67
|
+
|
|
68
|
+
type Post = { id: string; title: string }
|
|
69
|
+
|
|
70
|
+
export const api = createApi({
|
|
71
|
+
reducerPath: 'api',
|
|
72
|
+
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
|
|
73
|
+
tagTypes: ['Post'],
|
|
74
|
+
endpoints: (build) => ({
|
|
75
|
+
getPosts: build.query<Post[], void>({
|
|
76
|
+
query: () => 'posts',
|
|
77
|
+
providesTags: ['Post'],
|
|
78
|
+
}),
|
|
79
|
+
}),
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
If the problem is server data that should be cached and re-used, start with RTK Query instead of a thunk.
|
|
84
|
+
|
|
85
|
+
### Use `createAsyncThunk` for imperative workflows
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
|
|
89
|
+
|
|
90
|
+
type Draft = { title: string }
|
|
91
|
+
|
|
92
|
+
export const draftSaved = createAsyncThunk(
|
|
93
|
+
'drafts/save',
|
|
94
|
+
async (draft: Draft) => {
|
|
95
|
+
const response = await fetch('/api/drafts', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'Content-Type': 'application/json' },
|
|
98
|
+
body: JSON.stringify(draft),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return (await response.json()) as { id: string; title: string }
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const draftsSlice = createSlice({
|
|
106
|
+
name: 'drafts',
|
|
107
|
+
initialState: { status: 'idle' as 'idle' | 'pending' | 'failed' },
|
|
108
|
+
reducers: {},
|
|
109
|
+
extraReducers: (builder) => {
|
|
110
|
+
builder
|
|
111
|
+
.addCase(draftSaved.pending, (state) => {
|
|
112
|
+
state.status = 'pending'
|
|
113
|
+
})
|
|
114
|
+
.addCase(draftSaved.fulfilled, (state) => {
|
|
115
|
+
state.status = 'idle'
|
|
116
|
+
})
|
|
117
|
+
.addCase(draftSaved.rejected, (state) => {
|
|
118
|
+
state.status = 'failed'
|
|
119
|
+
})
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Use a thunk when you need one imperative async workflow with `dispatch` and `getState`.
|
|
125
|
+
|
|
126
|
+
### Use listener middleware for reactive workflows
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { createListenerMiddleware, createSlice } from '@reduxjs/toolkit'
|
|
130
|
+
|
|
131
|
+
const docsSlice = createSlice({
|
|
132
|
+
name: 'docs',
|
|
133
|
+
initialState: { status: 'idle' as 'idle' | 'saved' },
|
|
134
|
+
reducers: {
|
|
135
|
+
saveFinished(state) {
|
|
136
|
+
state.status = 'saved'
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const notificationsSlice = createSlice({
|
|
142
|
+
name: 'notifications',
|
|
143
|
+
initialState: [] as string[],
|
|
144
|
+
reducers: {
|
|
145
|
+
notificationQueued(state, action: { payload: string }) {
|
|
146
|
+
state.push(action.payload)
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const listenerMiddleware = createListenerMiddleware()
|
|
152
|
+
|
|
153
|
+
listenerMiddleware.startListening({
|
|
154
|
+
actionCreator: docsSlice.actions.saveFinished,
|
|
155
|
+
effect: async (_action, listenerApi) => {
|
|
156
|
+
listenerApi.dispatch(
|
|
157
|
+
notificationsSlice.actions.notificationQueued('Document saved'),
|
|
158
|
+
)
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Listeners fit workflows that react to future actions or state changes over time instead of driving one imperative request from a single callsite.
|
|
164
|
+
|
|
165
|
+
## Common Mistakes
|
|
166
|
+
|
|
167
|
+
### CRITICAL Running side effects inside reducers
|
|
168
|
+
|
|
169
|
+
Wrong:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
const todosSlice = createSlice({
|
|
173
|
+
name: 'todos',
|
|
174
|
+
initialState: [] as { id: string }[],
|
|
175
|
+
reducers: {
|
|
176
|
+
todoSaved(state, action: { payload: { id: string } }) {
|
|
177
|
+
fetch('/api/todos', { method: 'POST' })
|
|
178
|
+
state.push(action.payload)
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Correct:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { createAsyncThunk } from '@reduxjs/toolkit'
|
|
188
|
+
|
|
189
|
+
const todoSaved = createAsyncThunk('todos/save', async (todo: { id: string }) => {
|
|
190
|
+
await fetch('/api/todos', {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: { 'Content-Type': 'application/json' },
|
|
193
|
+
body: JSON.stringify(todo),
|
|
194
|
+
})
|
|
195
|
+
return todo
|
|
196
|
+
})
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Reducers must stay pure even when Immer is available.
|
|
200
|
+
|
|
201
|
+
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
|
202
|
+
|
|
203
|
+
### HIGH Using thunks to watch future state changes
|
|
204
|
+
|
|
205
|
+
Wrong:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
export const waitForSave = () => async (
|
|
209
|
+
_dispatch: unknown,
|
|
210
|
+
getState: () => { docs: { status: string } },
|
|
211
|
+
) => {
|
|
212
|
+
while (getState().docs.status !== 'saved') {
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Correct:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
startAppListening({
|
|
222
|
+
predicate: (_action, currentState) => currentState.docs.status === 'saved',
|
|
223
|
+
effect: async () => {
|
|
224
|
+
console.log('Document saved')
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Polling inside thunks fights the architecture; listener middleware is the reactive tool.
|
|
230
|
+
|
|
231
|
+
Source: reduxjs/redux-toolkit:docs/api/createListenerMiddleware.mdx
|
|
232
|
+
|
|
233
|
+
### HIGH Appending listener middleware after the default checks
|
|
234
|
+
|
|
235
|
+
Wrong:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'
|
|
239
|
+
|
|
240
|
+
const reducer = (state = { ready: true }) => state
|
|
241
|
+
const listenerMiddleware = createListenerMiddleware()
|
|
242
|
+
|
|
243
|
+
const store = configureStore({
|
|
244
|
+
reducer,
|
|
245
|
+
middleware: (getDefaultMiddleware) =>
|
|
246
|
+
getDefaultMiddleware().concat(listenerMiddleware.middleware),
|
|
247
|
+
})
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Correct:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'
|
|
254
|
+
|
|
255
|
+
const reducer = (state = { ready: true }) => state
|
|
256
|
+
const listenerMiddleware = createListenerMiddleware()
|
|
257
|
+
|
|
258
|
+
const store = configureStore({
|
|
259
|
+
reducer,
|
|
260
|
+
middleware: (getDefaultMiddleware) =>
|
|
261
|
+
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
|
262
|
+
})
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Listener add and remove actions may carry functions, so the listener middleware needs to run before serializability checks.
|
|
266
|
+
|
|
267
|
+
Source: reduxjs/redux-toolkit:docs/api/createListenerMiddleware.mdx
|
|
268
|
+
|
|
269
|
+
## References
|
|
270
|
+
|
|
271
|
+
- [Listener helpers and decision heuristics](references/listener-workflows.md)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Listener Workflows
|
|
2
|
+
|
|
3
|
+
## Decision table
|
|
4
|
+
|
|
5
|
+
| Need | Reach for |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| Cached server data | RTK Query |
|
|
8
|
+
| One imperative async workflow with `dispatch` / `getState` | `createAsyncThunk` or a thunk |
|
|
9
|
+
| React to later actions or state transitions | `createListenerMiddleware` |
|
|
10
|
+
|
|
11
|
+
A good app often mixes imperative and reactive workflows. The split is by job, not by ideology.
|
|
12
|
+
|
|
13
|
+
## Useful listener helpers
|
|
14
|
+
|
|
15
|
+
- `predicate`: react to any action when a state condition becomes true
|
|
16
|
+
- `condition`: wait until a condition becomes true before continuing
|
|
17
|
+
- `take`: wait for the next matching action
|
|
18
|
+
- `cancelActiveListeners`: cancel older instances of the same workflow
|
|
19
|
+
- `fork`: start a child task
|
|
20
|
+
|
|
21
|
+
## Example: cancel stale work
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
startAppListening({
|
|
25
|
+
actionCreator: searchRequested,
|
|
26
|
+
effect: async (action, listenerApi) => {
|
|
27
|
+
listenerApi.cancelActiveListeners()
|
|
28
|
+
await listenerApi.delay(250)
|
|
29
|
+
listenerApi.dispatch(searchStarted(action.payload))
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This is the kind of long-lived reactive behavior that does not fit a thunk well.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Middleware } from 'redux'
|
|
2
|
+
import { isActionCreator as isRTKAction } from './createAction'
|
|
3
|
+
|
|
4
|
+
export interface ActionCreatorInvariantMiddlewareOptions {
|
|
5
|
+
/**
|
|
6
|
+
* The function to identify whether a value is an action creator.
|
|
7
|
+
* The default checks for a function with a static type property and match method.
|
|
8
|
+
*/
|
|
9
|
+
isActionCreator?: (action: unknown) => action is Function & { type?: unknown }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getMessage(type?: unknown) {
|
|
13
|
+
const splitType = type ? `${type}`.split('/') : []
|
|
14
|
+
const actionName = splitType[splitType.length - 1] || 'actionCreator'
|
|
15
|
+
return `Detected an action creator with type "${
|
|
16
|
+
type || 'unknown'
|
|
17
|
+
}" being dispatched.
|
|
18
|
+
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.`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createActionCreatorInvariantMiddleware(
|
|
22
|
+
options: ActionCreatorInvariantMiddlewareOptions = {},
|
|
23
|
+
): Middleware {
|
|
24
|
+
if (process.env.NODE_ENV === 'production') {
|
|
25
|
+
return () => (next) => (action) => next(action)
|
|
26
|
+
}
|
|
27
|
+
const { isActionCreator = isRTKAction } = options
|
|
28
|
+
return () => (next) => (action) => {
|
|
29
|
+
if (isActionCreator(action)) {
|
|
30
|
+
console.warn(getMessage(action.type))
|
|
31
|
+
}
|
|
32
|
+
return next(action)
|
|
33
|
+
}
|
|
34
|
+
}
|