Add settings page for managing forecasting API key and URL via UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cae411eae7
commit
1c411e402e
19809 changed files with 1962608 additions and 97 deletions
304
frontend/node_modules/@reduxjs/toolkit/skills/build-modern-redux-apps/modern-redux/SKILL.md
generated
vendored
Normal file
304
frontend/node_modules/@reduxjs/toolkit/skills/build-modern-redux-apps/modern-redux/SKILL.md
generated
vendored
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
---
|
||||
name: build-modern-redux-apps/modern-redux
|
||||
description: >
|
||||
Use this when setting up a new Redux Toolkit app or modernizing an existing
|
||||
React + Redux codebase. Covers configureStore, Provider wiring, typed hooks,
|
||||
hooks-first React-Redux usage, feature folders, and the correct store lifetime
|
||||
for SPA and SSR-heavy React environments.
|
||||
type: lifecycle
|
||||
library: "@reduxjs/toolkit"
|
||||
library_version: "2.11.2"
|
||||
requires:
|
||||
- build-modern-redux-apps/redux-dataflow
|
||||
sources:
|
||||
- "reduxjs/redux-toolkit:docs/tutorials/quick-start.mdx"
|
||||
- "reduxjs/redux-toolkit:docs/tutorials/typescript.md"
|
||||
- "reduxjs/redux-toolkit:docs/usage/migrating-to-modern-redux.mdx"
|
||||
- "reduxjs/redux-toolkit:docs/usage/nextjs.mdx"
|
||||
- "reduxjs/redux:docs/style-guide/style-guide.md"
|
||||
---
|
||||
|
||||
# Modern Redux
|
||||
|
||||
## Setup
|
||||
|
||||
```tsx
|
||||
// file: src/features/counter/counterSlice.ts
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
export const counterSlice = createSlice({
|
||||
name: 'counter',
|
||||
initialState: { value: 0 },
|
||||
reducers: {
|
||||
increment(state) {
|
||||
state.value += 1
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { increment } = counterSlice.actions
|
||||
|
||||
// file: src/app/store.ts
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import { counterSlice } from '../features/counter/counterSlice'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
counter: counterSlice.reducer,
|
||||
},
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
|
||||
// file: src/app/hooks.ts
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import type { AppDispatch, RootState } from './store'
|
||||
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
|
||||
export const useAppSelector = useSelector.withTypes<RootState>()
|
||||
|
||||
// file: src/features/counter/Counter.tsx
|
||||
import { increment } from './counterSlice'
|
||||
import { useAppDispatch, useAppSelector } from '../../app/hooks'
|
||||
|
||||
export function Counter() {
|
||||
const value = useAppSelector((state) => state.counter.value)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return <button onClick={() => dispatch(increment())}>{value}</button>
|
||||
}
|
||||
|
||||
// file: src/main.tsx
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { Provider } from 'react-redux'
|
||||
import { store } from './app/store'
|
||||
import { Counter } from './features/counter/Counter'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<Provider store={store}>
|
||||
<Counter />
|
||||
</Provider>,
|
||||
)
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Keep React components on hooks, not wrappers
|
||||
|
||||
```tsx
|
||||
import { postAdded, selectPosts } from './postsSlice'
|
||||
import { useAppDispatch, useAppSelector } from '../../app/hooks'
|
||||
|
||||
export function PostsList() {
|
||||
const posts = useAppSelector(selectPosts)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
dispatch(postAdded({ id: 'p2', title: 'Write docs' }))
|
||||
}
|
||||
>
|
||||
{posts.length}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Hooks are the default React-Redux integration for new code.
|
||||
|
||||
### Create the store inside the provider for SSR-heavy React apps
|
||||
|
||||
```tsx
|
||||
// file: src/lib/store.ts
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import { counterSlice } from '../features/counter/counterSlice'
|
||||
|
||||
export const makeStore = () =>
|
||||
configureStore({
|
||||
reducer: {
|
||||
counter: counterSlice.reducer,
|
||||
},
|
||||
})
|
||||
|
||||
export type AppStore = ReturnType<typeof makeStore>
|
||||
export type RootState = ReturnType<AppStore['getState']>
|
||||
export type AppDispatch = AppStore['dispatch']
|
||||
|
||||
// file: src/app/StoreProvider.tsx
|
||||
'use client'
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { makeStore } from '../lib/store'
|
||||
|
||||
export function StoreProvider({ children }: { children: ReactNode }) {
|
||||
const [store] = useState(makeStore)
|
||||
|
||||
return <Provider store={store}>{children}</Provider>
|
||||
}
|
||||
```
|
||||
|
||||
In SSR-heavy React frameworks, create a store per request and keep that instance stable across renders.
|
||||
|
||||
### Keep app wiring in `app/` and feature logic in feature folders
|
||||
|
||||
```text
|
||||
src/
|
||||
app/
|
||||
store.ts
|
||||
hooks.ts
|
||||
features/
|
||||
posts/
|
||||
postsSlice.ts
|
||||
PostsList.tsx
|
||||
users/
|
||||
usersSlice.ts
|
||||
UsersList.tsx
|
||||
```
|
||||
|
||||
This keeps store wiring centralized and feature logic colocated.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### HIGH Importing the store in React components
|
||||
|
||||
Wrong:
|
||||
|
||||
```tsx
|
||||
import { store } from '../../app/store'
|
||||
|
||||
export function PostsList() {
|
||||
const posts = store.getState().posts
|
||||
return <div>{posts.length}</div>
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```tsx
|
||||
import { useAppSelector } from '../../app/hooks'
|
||||
|
||||
export function PostsList() {
|
||||
const posts = useAppSelector((state) => state.posts)
|
||||
return <div>{posts.length}</div>
|
||||
}
|
||||
```
|
||||
|
||||
React components should read through context and hooks; direct store imports are a separate escape hatch for non-React integrations, not the default UI pattern.
|
||||
|
||||
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
||||
|
||||
### HIGH Defaulting to `connect()` in new React code
|
||||
|
||||
Wrong:
|
||||
|
||||
```tsx
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
const increment = () => ({ type: 'counter/increment' as const })
|
||||
const mapState = (state: { counter: { value: number } }) => ({
|
||||
value: state.counter.value,
|
||||
})
|
||||
const mapDispatch = { increment }
|
||||
|
||||
function Counter({ value, increment }: ReturnType<typeof mapState> & typeof mapDispatch) {
|
||||
return <button onClick={() => increment()}>{value}</button>
|
||||
}
|
||||
|
||||
export default connect(mapState, mapDispatch)(Counter)
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```tsx
|
||||
import { increment } from './counterSlice'
|
||||
import { useAppDispatch, useAppSelector } from '../../app/hooks'
|
||||
|
||||
export function Counter() {
|
||||
const value = useAppSelector((state) => state.counter.value)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return <button onClick={() => dispatch(increment())}>{value}</button>
|
||||
}
|
||||
```
|
||||
|
||||
Hooks are the modern default, simpler to type, and the maintainers explicitly want agents to steer new code away from `connect`.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/usage/migrating-to-modern-redux.mdx
|
||||
|
||||
### HIGH Recreating the store during render in SSR-heavy apps
|
||||
|
||||
Wrong:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { Provider } from 'react-redux'
|
||||
import { makeStore } from '../lib/store'
|
||||
|
||||
export function StoreProvider({ children }: { children: import('react').ReactNode }) {
|
||||
const store = makeStore()
|
||||
return <Provider store={store}>{children}</Provider>
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { makeStore } from '../lib/store'
|
||||
|
||||
export function StoreProvider({ children }: { children: import('react').ReactNode }) {
|
||||
const [store] = useState(makeStore)
|
||||
return <Provider store={store}>{children}</Provider>
|
||||
}
|
||||
```
|
||||
|
||||
A new store on every render loses client state, while a module singleton can leak across requests on the server.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/usage/nextjs.mdx
|
||||
|
||||
### HIGH Keeping `createStore` boilerplate as the default
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { applyMiddleware, combineReducers, createStore } from 'redux'
|
||||
import thunk from 'redux-thunk'
|
||||
|
||||
const counterReducer = (state = { value: 0 }) => state
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
counter: counterReducer,
|
||||
})
|
||||
|
||||
export const store = createStore(rootReducer, applyMiddleware(thunk))
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
|
||||
const counterReducer = (state = { value: 0 }) => state
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
counter: counterReducer,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Manual store setup works, but it throws away RTK's default middleware, dev checks, and the current recommended baseline.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/usage/migrating-to-modern-redux.mdx
|
||||
|
||||
## References
|
||||
|
||||
- [Store lifetime and framework boundaries](references/store-lifetime.md)
|
||||
53
frontend/node_modules/@reduxjs/toolkit/skills/build-modern-redux-apps/modern-redux/references/store-lifetime.md
generated
vendored
Normal file
53
frontend/node_modules/@reduxjs/toolkit/skills/build-modern-redux-apps/modern-redux/references/store-lifetime.md
generated
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Store Lifetime
|
||||
|
||||
## Decision table
|
||||
|
||||
| Environment | Default store shape | Why |
|
||||
| --- | --- | --- |
|
||||
| Client-only SPA | One module-level singleton store | There is one browser session and no cross-request leakage risk. |
|
||||
| SSR-heavy React app | `makeStore()` plus provider-local state | Each request needs its own store instance, but that instance must stay stable across client renders. |
|
||||
| Non-React integration code | Direct store access can be acceptable | This is outside the React context boundary and should stay out of UI components. |
|
||||
|
||||
## SPA pattern
|
||||
|
||||
```ts
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import { postsSlice } from '../features/posts/postsSlice'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
posts: postsSlice.reducer,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Use this for classic browser SPAs.
|
||||
|
||||
## SSR-heavy React pattern
|
||||
|
||||
```tsx
|
||||
// file: src/lib/store.ts
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import { postsSlice } from '../features/posts/postsSlice'
|
||||
|
||||
export const makeStore = () =>
|
||||
configureStore({
|
||||
reducer: {
|
||||
posts: postsSlice.reducer,
|
||||
},
|
||||
})
|
||||
|
||||
// file: src/app/StoreProvider.tsx
|
||||
'use client'
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { makeStore } from '../lib/store'
|
||||
|
||||
export function StoreProvider({ children }: { children: ReactNode }) {
|
||||
const [store] = useState(makeStore)
|
||||
return <Provider store={store}>{children}</Provider>
|
||||
}
|
||||
```
|
||||
|
||||
Create the store per request, then keep it stable inside the provider component.
|
||||
264
frontend/node_modules/@reduxjs/toolkit/skills/build-modern-redux-apps/redux-dataflow/SKILL.md
generated
vendored
Normal file
264
frontend/node_modules/@reduxjs/toolkit/skills/build-modern-redux-apps/redux-dataflow/SKILL.md
generated
vendored
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
---
|
||||
name: build-modern-redux-apps/redux-dataflow
|
||||
description: >
|
||||
Use this when you need the Redux event -> reducer -> selector -> render loop,
|
||||
event-style actions, reducer-owned state transitions, derived data, or a
|
||||
debugging model for Redux Toolkit apps.
|
||||
type: core
|
||||
library: "@reduxjs/toolkit"
|
||||
library_version: "2.11.2"
|
||||
sources:
|
||||
- "reduxjs/redux:docs/tutorials/fundamentals/part-2-concepts-data-flow.md"
|
||||
- "reduxjs/redux:docs/tutorials/essentials/part-3-data-flow.md"
|
||||
- "reduxjs/redux:docs/style-guide/style-guide.md"
|
||||
---
|
||||
|
||||
# Redux Dataflow
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
import { configureStore, createSelector, createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const postsSlice = createSlice({
|
||||
name: 'posts',
|
||||
initialState: {
|
||||
items: [] as { id: string; title: string; published: boolean }[],
|
||||
filter: 'all' as 'all' | 'published',
|
||||
},
|
||||
reducers: {
|
||||
postAdded(state, action: { payload: { id: string; title: string } }) {
|
||||
state.items.push({ ...action.payload, published: false })
|
||||
},
|
||||
postPublished(state, action: { payload: { id: string } }) {
|
||||
const post = state.items.find((item) => item.id === action.payload.id)
|
||||
if (post) {
|
||||
post.published = true
|
||||
}
|
||||
},
|
||||
filterChanged(state, action: { payload: 'all' | 'published' }) {
|
||||
state.filter = action.payload
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
posts: postsSlice.reducer,
|
||||
},
|
||||
})
|
||||
|
||||
type RootState = ReturnType<typeof store.getState>
|
||||
|
||||
const selectPostsState = (state: RootState) => state.posts
|
||||
const selectVisiblePosts = createSelector([selectPostsState], (postsState) =>
|
||||
postsState.filter === 'all'
|
||||
? postsState.items
|
||||
: postsState.items.filter((post) => post.published),
|
||||
)
|
||||
|
||||
store.dispatch(postsSlice.actions.postAdded({ id: 'p1', title: 'Draft' }))
|
||||
store.dispatch(postsSlice.actions.postPublished({ id: 'p1' }))
|
||||
|
||||
const visiblePosts = selectVisiblePosts(store.getState())
|
||||
console.log(visiblePosts)
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Dispatch events, not setters
|
||||
|
||||
```ts
|
||||
const postsSlice = createSlice({
|
||||
name: 'posts',
|
||||
initialState: [] as { id: string; title: string }[],
|
||||
reducers: {
|
||||
postAdded(state, action: { payload: { id: string; title: string } }) {
|
||||
state.push(action.payload)
|
||||
},
|
||||
postRemoved(state, action: { payload: { id: string } }) {
|
||||
return state.filter((post) => post.id !== action.payload.id)
|
||||
},
|
||||
postUpdated(
|
||||
state,
|
||||
action: { payload: { id: string; changes: Partial<{ title: string }> } },
|
||||
) {
|
||||
const post = state.find((item) => item.id === action.payload.id)
|
||||
if (post && action.payload.changes.title) {
|
||||
post.title = action.payload.changes.title
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
postsSlice.actions.postAdded({ id: 'p1', title: 'Draft' })
|
||||
```
|
||||
|
||||
Event-style actions explain what happened in the UI instead of hiding the transition behind a generic setter.
|
||||
|
||||
### Let reducers combine old store data with new outside data
|
||||
|
||||
```ts
|
||||
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const postsAdapter = createEntityAdapter<{ id: string; title: string }>()
|
||||
|
||||
const postsSlice = createSlice({
|
||||
name: 'posts',
|
||||
initialState: postsAdapter.getInitialState(),
|
||||
reducers: {
|
||||
postsReceived(state, action: { payload: { id: string; title: string }[] }) {
|
||||
postsAdapter.upsertMany(state, action.payload)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const incomingPosts = [
|
||||
{ id: 'p1', title: 'Draft' },
|
||||
{ id: 'p2', title: 'Published' },
|
||||
]
|
||||
|
||||
postsSlice.actions.postsReceived(incomingPosts)
|
||||
```
|
||||
|
||||
If a transition mixes current store state with new external data, dispatch the new external data and let the reducer own the merge.
|
||||
|
||||
### Derive values with selectors instead of storing duplicates
|
||||
|
||||
```ts
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
|
||||
const selectPosts = (state: RootState) => state.posts.items
|
||||
const selectFilter = (state: RootState) => state.posts.filter
|
||||
|
||||
export const selectVisiblePosts = createSelector(
|
||||
[selectPosts, selectFilter],
|
||||
(posts, filter) =>
|
||||
filter === 'all'
|
||||
? posts
|
||||
: posts.filter((post) => post.published),
|
||||
)
|
||||
```
|
||||
|
||||
Selectors keep a single source of truth in state while still exposing the shapes the UI needs.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### CRITICAL Mutating selected state outside reducers
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
const post = selectPostById(store.getState(), 'p1')
|
||||
|
||||
if (post) {
|
||||
post.title = 'Changed in place'
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
store.dispatch(postUpdated({ id: 'p1', changes: { title: 'Changed in place' } }))
|
||||
```
|
||||
|
||||
Objects read from the store are still store state; mutating them outside reducers breaks immutability and stale-render assumptions.
|
||||
|
||||
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
||||
|
||||
### HIGH Using setter-style actions instead of event-style actions
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
const nextPosts = [...selectPosts(store.getState()), { id: 'p2', title: 'Write docs' }]
|
||||
store.dispatch(setPosts(nextPosts))
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
store.dispatch(postAdded({ id: 'p2', title: 'Write docs' }))
|
||||
```
|
||||
|
||||
Actions should describe events, not ask reducers to blindly replace state with a precomputed value.
|
||||
|
||||
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
||||
|
||||
### HIGH Combining store state before dispatch
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
const currentPosts = selectPosts(store.getState())
|
||||
const mergedPosts = [
|
||||
...currentPosts.filter(
|
||||
(currentPost) =>
|
||||
!incomingPosts.some((incomingPost) => incomingPost.id === currentPost.id),
|
||||
),
|
||||
...incomingPosts,
|
||||
]
|
||||
|
||||
store.dispatch(postsReplaced(mergedPosts))
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
store.dispatch(postsReceived(incomingPosts))
|
||||
```
|
||||
|
||||
If the next state depends on current store state, the reducer should own that combination logic; only authoritative external snapshots should replace state wholesale.
|
||||
|
||||
Source: maintainer interview
|
||||
|
||||
### HIGH Ignoring current state in async reducers
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
builder.addCase(fetchPosts.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded'
|
||||
state.items = action.payload
|
||||
})
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
builder.addCase(fetchPosts.fulfilled, (state, action) => {
|
||||
if (state.status === 'pending') {
|
||||
state.status = 'succeeded'
|
||||
state.items = action.payload
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Reducers that treat every lifecycle action as valid can move the slice into impossible states or let stale requests win.
|
||||
|
||||
Source: reduxjs/redux:docs/tutorials/essentials/part-5-async-logic.md
|
||||
|
||||
### MEDIUM Storing derived values in state
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
const initialState = {
|
||||
items: [] as Post[],
|
||||
visiblePosts: [] as Post[],
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
const selectVisiblePosts = createSelector(
|
||||
[selectPosts, selectFilter],
|
||||
(posts, filter) =>
|
||||
filter === 'all' ? posts : posts.filter((post) => post.published),
|
||||
)
|
||||
```
|
||||
|
||||
Derived values drift out of sync quickly; keep the raw state and derive the view shape.
|
||||
|
||||
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
||||
269
frontend/node_modules/@reduxjs/toolkit/skills/evolve-and-diagnose-redux-apps/debug-redux-toolkit-apps/SKILL.md
generated
vendored
Normal file
269
frontend/node_modules/@reduxjs/toolkit/skills/evolve-and-diagnose-redux-apps/debug-redux-toolkit-apps/SKILL.md
generated
vendored
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
---
|
||||
name: evolve-and-diagnose-redux-apps/debug-redux-toolkit-apps
|
||||
description: >
|
||||
Use this when debugging duplicate requests, stale cache behavior, broad
|
||||
subscriptions, selector churn, serializability warnings, or other Redux
|
||||
Toolkit and RTK Query bugs. Covers a practical event -> reducer -> selector ->
|
||||
render debugging loop plus RTK Query cache interpretation.
|
||||
type: lifecycle
|
||||
library: "@reduxjs/toolkit"
|
||||
library_version: "2.11.2"
|
||||
requires:
|
||||
- build-modern-redux-apps/redux-dataflow
|
||||
sources:
|
||||
- "reduxjs/redux:docs/style-guide/style-guide.md"
|
||||
- "reduxjs/redux:docs/tutorials/essentials/part-5-async-logic.md"
|
||||
- "reduxjs/redux:docs/tutorials/essentials/part-8-rtk-query-advanced.md"
|
||||
- "reduxjs/redux-toolkit:docs/usage/usage-guide.md"
|
||||
---
|
||||
|
||||
# Debug Redux Toolkit Apps
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
import { configureStore, createAsyncThunk, createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
type Post = { id: string; title: string }
|
||||
|
||||
export const fetchPosts = createAsyncThunk(
|
||||
'posts/fetchPosts',
|
||||
async () => {
|
||||
const response = await fetch('/api/posts')
|
||||
return (await response.json()) as Post[]
|
||||
},
|
||||
{
|
||||
condition(_arg, { getState }) {
|
||||
const state = getState() as RootState
|
||||
return state.posts.status === 'idle'
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const postsSlice = createSlice({
|
||||
name: 'posts',
|
||||
initialState: {
|
||||
items: [] as Post[],
|
||||
status: 'idle' as 'idle' | 'pending' | 'succeeded' | 'failed',
|
||||
},
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchPosts.pending, (state) => {
|
||||
state.status = 'pending'
|
||||
})
|
||||
.addCase(fetchPosts.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded'
|
||||
state.items = action.payload
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
posts: postsSlice.reducer,
|
||||
},
|
||||
})
|
||||
|
||||
type RootState = ReturnType<typeof store.getState>
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Debug in order: action -> reducer -> selector -> render
|
||||
|
||||
```ts
|
||||
const selectPosts = (state: RootState) => state.posts.items
|
||||
const selectPostsStatus = (state: RootState) => state.posts.status
|
||||
|
||||
store.dispatch(fetchPosts())
|
||||
|
||||
console.log(selectPostsStatus(store.getState()))
|
||||
console.log(selectPosts(store.getState()))
|
||||
```
|
||||
|
||||
If a component looks wrong, first verify the action fired, then the reducer state, then the selector result, then the render boundary.
|
||||
|
||||
### Narrow subscriptions at the usage site
|
||||
|
||||
```tsx
|
||||
import { useAppSelector } from '../../app/hooks'
|
||||
|
||||
export function PostsList() {
|
||||
const posts = useAppSelector((state) => state.posts.items)
|
||||
const status = useAppSelector((state) => state.posts.status)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{status}</div>
|
||||
<div>{posts.length}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
React-Redux behaves best when components select only the values they render and do it as close to usage as possible.
|
||||
|
||||
### Interpret RTK Query invalidation correctly
|
||||
|
||||
```ts
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
|
||||
type Post = { id: string; title: string }
|
||||
|
||||
const api = createApi({
|
||||
reducerPath: 'api',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
|
||||
tagTypes: ['Post'],
|
||||
endpoints: (build) => ({
|
||||
getPosts: build.query<Post[], void>({
|
||||
query: () => 'posts',
|
||||
providesTags: ['Post'],
|
||||
}),
|
||||
updatePost: build.mutation<Post, Pick<Post, 'id' | 'title'>>({
|
||||
query: ({ id, title }) => ({
|
||||
url: `posts/${id}`,
|
||||
method: 'PATCH',
|
||||
body: { title },
|
||||
}),
|
||||
invalidatesTags: ['Post'],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
If invalidation did not visibly refetch, check whether anything was still subscribed to that cache entry.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### HIGH Dispatching fetch thunks from effects without a thunk-level guard
|
||||
|
||||
Wrong:
|
||||
|
||||
```tsx
|
||||
import { useEffect } from 'react'
|
||||
import { useAppDispatch, useAppSelector } from '../../app/hooks'
|
||||
|
||||
function PostsPage() {
|
||||
const dispatch = useAppDispatch()
|
||||
const postStatus = useAppSelector((state) => state.posts.status)
|
||||
|
||||
useEffect(() => {
|
||||
if (postStatus === 'idle') {
|
||||
dispatch(fetchPosts())
|
||||
}
|
||||
}, [dispatch, postStatus])
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
export const fetchPosts = createAsyncThunk(
|
||||
'posts/fetchPosts',
|
||||
async () => {
|
||||
const response = await fetch('/api/posts')
|
||||
return (await response.json()) as Post[]
|
||||
},
|
||||
{
|
||||
condition(_arg, { getState }) {
|
||||
const state = getState() as RootState
|
||||
return state.posts.status === 'idle'
|
||||
},
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
React StrictMode can run effects twice in development, so the guard belongs in the thunk as well as the component.
|
||||
|
||||
Source: reduxjs/redux:docs/tutorials/essentials/part-5-async-logic.md
|
||||
|
||||
### HIGH Ignoring serializable-state warnings
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
const initialState = {
|
||||
lastSeen: new Date(),
|
||||
pendingIds: new Set<string>(),
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
const initialState = {
|
||||
lastSeenIso: new Date().toISOString(),
|
||||
pendingIds: [] as string[],
|
||||
}
|
||||
```
|
||||
|
||||
Non-serializable values break DevTools, replay, persistence, and equality assumptions in subtle ways.
|
||||
|
||||
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
||||
|
||||
### HIGH Selecting broad state in parents and threading props
|
||||
|
||||
Wrong:
|
||||
|
||||
```tsx
|
||||
import { useAppSelector } from '../../app/hooks'
|
||||
|
||||
function PostsPage() {
|
||||
const postsState = useAppSelector((state) => state.posts)
|
||||
return <PostsList items={postsState.items} status={postsState.status} />
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```tsx
|
||||
import { useAppSelector } from '../../app/hooks'
|
||||
|
||||
function PostsList() {
|
||||
const items = useAppSelector((state) => state.posts.items)
|
||||
const status = useAppSelector((state) => state.posts.status)
|
||||
return (
|
||||
<div>
|
||||
<div>{status}</div>
|
||||
<div>{items.length}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Selecting whole slices high in the tree widens the subscription surface and pushes rerenders through props.
|
||||
|
||||
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
||||
|
||||
### MEDIUM Returning unstable objects from query selection logic
|
||||
|
||||
Wrong:
|
||||
|
||||
```tsx
|
||||
import { api } from '../../services/api'
|
||||
|
||||
const result = api.useGetPostsQuery(undefined, {
|
||||
selectFromResult: ({ data = [] }) => ({
|
||||
posts: [...data],
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```tsx
|
||||
import { api } from '../../services/api'
|
||||
|
||||
const result = api.useGetPostsQuery(undefined, {
|
||||
selectFromResult: ({ data = [] }) => ({
|
||||
posts: data,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
New object and array references defeat memoization and make components rerender even when the underlying cached data did not change.
|
||||
|
||||
Source: reduxjs/redux:docs/tutorials/essentials/part-8-rtk-query-advanced.md
|
||||
226
frontend/node_modules/@reduxjs/toolkit/skills/evolve-and-diagnose-redux-apps/migrate-to-modern-redux/SKILL.md
generated
vendored
Normal file
226
frontend/node_modules/@reduxjs/toolkit/skills/evolve-and-diagnose-redux-apps/migrate-to-modern-redux/SKILL.md
generated
vendored
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
---
|
||||
name: evolve-and-diagnose-redux-apps/migrate-to-modern-redux
|
||||
description: >
|
||||
Use this when moving a legacy Redux codebase to current RTK patterns. Covers
|
||||
replacing createStore with configureStore, migrating touched reducers to
|
||||
createSlice, codemod-assisted RTK 2 updates, and replacing server-data stacks
|
||||
with RTK Query instead of writing new legacy Redux code.
|
||||
type: lifecycle
|
||||
library: "@reduxjs/toolkit"
|
||||
library_version: "2.11.2"
|
||||
requires:
|
||||
- build-modern-redux-apps/modern-redux
|
||||
sources:
|
||||
- "reduxjs/redux-toolkit:docs/usage/migrating-to-modern-redux.mdx"
|
||||
- "reduxjs/redux-toolkit:docs/usage/migrating-rtk-2.md"
|
||||
- "reduxjs/redux-toolkit:packages/rtk-codemods/README.md"
|
||||
- "reduxjs/redux:docs/style-guide/style-guide.md"
|
||||
---
|
||||
|
||||
# Migrate To Modern Redux
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// before
|
||||
import { applyMiddleware, combineReducers, createStore } from 'redux'
|
||||
import thunk from 'redux-thunk'
|
||||
|
||||
const postsReducer = (state = [] as { id: string; title: string }[]) => state
|
||||
const usersReducer = (state = [] as { id: string; name: string }[]) => state
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
posts: postsReducer,
|
||||
users: usersReducer,
|
||||
})
|
||||
|
||||
export const legacyStore = createStore(rootReducer, applyMiddleware(thunk))
|
||||
|
||||
// after
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
|
||||
const postsReducer = (state = [] as { id: string; title: string }[]) => state
|
||||
const usersReducer = (state = [] as { id: string; name: string }[]) => state
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
posts: postsReducer,
|
||||
users: usersReducer,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Replace the store setup first
|
||||
|
||||
```ts
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
|
||||
const postsReducer = (state = [] as { id: string; title: string }[]) => state
|
||||
const usersReducer = (state = [] as { id: string; name: string }[]) => state
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
posts: postsReducer,
|
||||
users: usersReducer,
|
||||
},
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
```
|
||||
|
||||
This is the one migration step that can happen immediately while old reducers continue to work.
|
||||
|
||||
### Migrate reducers as you touch them
|
||||
|
||||
```ts
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
type TodosState = {
|
||||
items: { id: string; text: string; completed: boolean }[]
|
||||
}
|
||||
|
||||
const initialState: TodosState = {
|
||||
items: [],
|
||||
}
|
||||
|
||||
export const todosSlice = createSlice({
|
||||
name: 'todos',
|
||||
initialState,
|
||||
reducers: {
|
||||
todoAdded(state, action: { payload: { id: string; text: string } }) {
|
||||
state.items.push({ ...action.payload, completed: false })
|
||||
},
|
||||
todoToggled(state, action: { payload: { id: string } }) {
|
||||
const todo = state.items.find((item) => item.id === action.payload.id)
|
||||
if (todo) {
|
||||
todo.completed = !todo.completed
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Once a reducer needs editing, migrate that reducer instead of adding more legacy code to it.
|
||||
|
||||
### Use codemods for mechanical updates
|
||||
|
||||
```bash
|
||||
npx @reduxjs/rtk-codemods createSliceBuilder src/features/posts/postsSlice.ts
|
||||
npx @reduxjs/rtk-codemods createReducerBuilder src/features/posts/postsReducer.ts
|
||||
```
|
||||
|
||||
Use codemods for repetitive RTK API migrations, then review the result and finish the semantic cleanup by hand.
|
||||
|
||||
### Replace legacy server-data stacks with RTK Query
|
||||
|
||||
```ts
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
|
||||
type Todo = { id: string; text: string }
|
||||
|
||||
export const api = createApi({
|
||||
reducerPath: 'api',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
|
||||
endpoints: (build) => ({
|
||||
getTodos: build.query<Todo[], void>({
|
||||
query: () => 'todos',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
When the old code is just request status plus fetched data, migrate toward RTK Query instead of carrying the thunk stack forward forever.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### HIGH Attempting a big-bang rewrite
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
// Replace every reducer, every connected component, and every async flow
|
||||
// in one branch before shipping anything.
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
// 1) Switch createStore to configureStore
|
||||
// 2) Migrate one touched reducer to createSlice
|
||||
// 3) Convert touched connected components to hooks
|
||||
// 4) Repeat without introducing new legacy Redux code
|
||||
```
|
||||
|
||||
Modern Redux migration is incremental, but once the store is modernized new work should stop adding legacy patterns.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/usage/migrating-to-modern-redux.mdx
|
||||
|
||||
### CRITICAL Carrying removed RTK 2 config forms forward
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
configureStore({
|
||||
reducer,
|
||||
middleware: [logger],
|
||||
})
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
configureStore({
|
||||
reducer,
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(logger),
|
||||
})
|
||||
```
|
||||
|
||||
RTK 2 removed array middleware configuration and other older builder forms that agents trained on RTK 1.x still emit.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/usage/migrating-rtk-2.md
|
||||
|
||||
### HIGH Preserving hand-written fetch state by default
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit'
|
||||
|
||||
type Todo = { id: string; text: string }
|
||||
|
||||
const initialState = {
|
||||
items: [] as Todo[],
|
||||
status: 'idle' as 'idle' | 'pending' | 'failed',
|
||||
}
|
||||
|
||||
export const fetchTodos = createAsyncThunk('todos/fetch', async () => {
|
||||
const response = await fetch('/api/todos')
|
||||
return (await response.json()) as Todo[]
|
||||
})
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
|
||||
type Todo = { id: string; text: string }
|
||||
|
||||
const api = createApi({
|
||||
reducerPath: 'api',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
|
||||
endpoints: (build) => ({
|
||||
getTodos: build.query<Todo[], void>({
|
||||
query: () => 'todos',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
If the feature is really server cache, keep the migration moving toward RTK Query instead of rebuilding the old loading-flag architecture in new APIs.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/usage/migrating-to-modern-redux.mdx
|
||||
385
frontend/node_modules/@reduxjs/toolkit/skills/manage-server-data/adopt-rtk-query/SKILL.md
generated
vendored
Normal file
385
frontend/node_modules/@reduxjs/toolkit/skills/manage-server-data/adopt-rtk-query/SKILL.md
generated
vendored
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
---
|
||||
name: manage-server-data/adopt-rtk-query
|
||||
description: >
|
||||
Use this when adding RTK Query as the default server-data and document-cache
|
||||
layer. Covers createApi, store integration, hooks, invalidation behavior,
|
||||
optimistic updates, and deciding when RTK Query is the right cache model.
|
||||
type: lifecycle
|
||||
library: "@reduxjs/toolkit"
|
||||
library_version: "2.11.2"
|
||||
requires:
|
||||
- build-modern-redux-apps/modern-redux
|
||||
sources:
|
||||
- "reduxjs/redux-toolkit:docs/rtk-query/api/createApi.mdx"
|
||||
- "reduxjs/redux-toolkit:docs/rtk-query/usage/automated-refetching.mdx"
|
||||
- "reduxjs/redux-toolkit:docs/rtk-query/usage/manual-cache-updates.mdx"
|
||||
- "reduxjs/redux-toolkit:docs/rtk-query/usage/persistence-and-rehydration.mdx"
|
||||
- "reduxjs/redux-toolkit:docs/tutorials/rtk-query.mdx"
|
||||
- "reduxjs/redux:docs/style-guide/style-guide.md"
|
||||
---
|
||||
|
||||
# Adopt RTK Query
|
||||
|
||||
## Setup
|
||||
|
||||
```tsx
|
||||
// file: src/services/api.ts
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
|
||||
type Post = { id: string; title: string }
|
||||
|
||||
export const api = createApi({
|
||||
reducerPath: 'api',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
|
||||
tagTypes: ['Post'],
|
||||
endpoints: (build) => ({
|
||||
getPosts: build.query<Post[], void>({
|
||||
query: () => 'posts',
|
||||
providesTags: (result) =>
|
||||
result
|
||||
? [...result.map(({ id }) => ({ type: 'Post' as const, id })), 'Post']
|
||||
: ['Post'],
|
||||
}),
|
||||
addPost: build.mutation<Post, Pick<Post, 'title'>>({
|
||||
query: (body) => ({
|
||||
url: 'posts',
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Post'],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const { useGetPostsQuery, useAddPostMutation } = api
|
||||
|
||||
// file: src/app/store.ts
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import { api } from '../services/api'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
[api.reducerPath]: api.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(api.middleware),
|
||||
})
|
||||
|
||||
// file: src/App.tsx
|
||||
import { Provider } from 'react-redux'
|
||||
import { store } from './app/store'
|
||||
import { useAddPostMutation, useGetPostsQuery } from './services/api'
|
||||
|
||||
function Posts() {
|
||||
const { data: posts = [] } = useGetPostsQuery()
|
||||
const [addPost] = useAddPostMutation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => addPost({ title: 'Write docs' })}>Add</button>
|
||||
<ul>
|
||||
{posts.map((post) => (
|
||||
<li key={post.id}>{post.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Posts />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Keep one API slice per base URL and extend it
|
||||
|
||||
```ts
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
|
||||
export const api = createApi({
|
||||
reducerPath: 'api',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
|
||||
endpoints: () => ({}),
|
||||
})
|
||||
|
||||
export const postsApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getPosts: build.query<{ id: string; title: string }[], void>({
|
||||
query: () => 'posts',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Split files with `injectEndpoints`, not by making multiple `createApi` roots for the same backend.
|
||||
|
||||
### Use tags for cache invalidation
|
||||
|
||||
```ts
|
||||
type Post = { id: string; title: string }
|
||||
|
||||
export const api = createApi({
|
||||
reducerPath: 'api',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
|
||||
tagTypes: ['Post'],
|
||||
endpoints: (build) => ({
|
||||
getPosts: build.query<Post[], void>({
|
||||
query: () => 'posts',
|
||||
providesTags: (result) =>
|
||||
result
|
||||
? [...result.map(({ id }) => ({ type: 'Post' as const, id })), 'Post']
|
||||
: ['Post'],
|
||||
}),
|
||||
updatePost: build.mutation<Post, Pick<Post, 'id' | 'title'>>({
|
||||
query: ({ id, title }) => ({
|
||||
url: `posts/${id}`,
|
||||
method: 'PATCH',
|
||||
body: { title },
|
||||
}),
|
||||
invalidatesTags: (_result, _error, { id }) => [{ type: 'Post', id }],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Treat tags as the normal invalidation path before reaching for manual cache patching.
|
||||
|
||||
### Do optimistic updates in endpoint lifecycles
|
||||
|
||||
```ts
|
||||
type Post = { id: string; title: string }
|
||||
|
||||
export const api = createApi({
|
||||
reducerPath: 'api',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
|
||||
tagTypes: ['Post'],
|
||||
endpoints: (build) => ({
|
||||
getPosts: build.query<Post[], void>({
|
||||
query: () => 'posts',
|
||||
providesTags: ['Post'],
|
||||
}),
|
||||
updatePostTitle: build.mutation<Post, Pick<Post, 'id' | 'title'>>({
|
||||
query: ({ id, title }) => ({
|
||||
url: `posts/${id}`,
|
||||
method: 'PATCH',
|
||||
body: { title },
|
||||
}),
|
||||
async onQueryStarted({ id, title }, { dispatch, queryFulfilled }) {
|
||||
const patch = dispatch(
|
||||
api.util.updateQueryData('getPosts', undefined, (draft) => {
|
||||
const post = draft.find((item) => item.id === id)
|
||||
if (post) {
|
||||
post.title = title
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
await queryFulfilled
|
||||
} catch {
|
||||
patch.undo()
|
||||
}
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Keep optimistic and pessimistic cache updates inside endpoint lifecycle handlers so they stay coupled to the request.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### CRITICAL Creating multiple API slices for one backend
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
|
||||
type User = { id: string; name: string }
|
||||
|
||||
const baseQuery = fetchBaseQuery({ baseUrl: '/api/' })
|
||||
|
||||
const postsApi = createApi({
|
||||
reducerPath: 'api',
|
||||
baseQuery,
|
||||
endpoints: () => ({}),
|
||||
})
|
||||
|
||||
const usersApi = createApi({
|
||||
reducerPath: 'api',
|
||||
baseQuery,
|
||||
endpoints: () => ({}),
|
||||
})
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
|
||||
type User = { id: string; name: string }
|
||||
|
||||
const baseQuery = fetchBaseQuery({ baseUrl: '/api/' })
|
||||
|
||||
const api = createApi({
|
||||
reducerPath: 'api',
|
||||
baseQuery,
|
||||
endpoints: () => ({}),
|
||||
})
|
||||
|
||||
const usersApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getUsers: build.query<User[], void>({ query: () => 'users' }),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
One API slice per base URL preserves invalidation behavior and avoids duplicated middleware work.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/rtk-query/api/createApi.mdx
|
||||
|
||||
### HIGH Forgetting `api.reducer` or `api.middleware`
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {},
|
||||
})
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
[api.reducerPath]: api.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(api.middleware),
|
||||
})
|
||||
```
|
||||
|
||||
RTK Query hooks need both the reducer and middleware to manage cache state and request lifecycles.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/tutorials/rtk-query.mdx
|
||||
|
||||
### MEDIUM Persisting browser API cache by default
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
const storage = window.localStorage
|
||||
|
||||
const persistConfig = {
|
||||
key: 'root',
|
||||
storage,
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
|
||||
const api = createApi({
|
||||
reducerPath: 'api',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
|
||||
endpoints: () => ({}),
|
||||
})
|
||||
```
|
||||
|
||||
Persisting RTK Query cache in browsers often keeps stale data around longer than users expect; treat persistence as a special case, not the default.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/rtk-query/usage/persistence-and-rehydration.mdx
|
||||
|
||||
### HIGH Patching cache from components
|
||||
|
||||
Wrong:
|
||||
|
||||
```tsx
|
||||
import { useEffect } from 'react'
|
||||
import { useAppDispatch } from '../../app/hooks'
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(api.util.updateQueryData('getPosts', undefined, (draft) => {
|
||||
draft.push({ id: 'p3', title: 'Patched from component' })
|
||||
}))
|
||||
}, [dispatch])
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
updatePostTitle: build.mutation<Post, Pick<Post, 'id' | 'title'>>({
|
||||
query: ({ id, title }) => ({
|
||||
url: `posts/${id}`,
|
||||
method: 'PATCH',
|
||||
body: { title },
|
||||
}),
|
||||
async onQueryStarted({ id, title }, { dispatch, queryFulfilled }) {
|
||||
const patch = dispatch(
|
||||
api.util.updateQueryData('getPosts', undefined, (draft) => {
|
||||
const post = draft.find((item) => item.id === id)
|
||||
if (post) {
|
||||
post.title = title
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
await queryFulfilled
|
||||
} catch {
|
||||
patch.undo()
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Component-level cache patches drift away from the mutation lifecycle that should own them.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/rtk-query/usage/manual-cache-updates.mdx
|
||||
|
||||
### HIGH Expecting invalidation to refetch unsubscribed queries
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { api } from './api'
|
||||
import { store } from './store'
|
||||
|
||||
const subscription = store.dispatch(api.endpoints.getPosts.initiate())
|
||||
subscription.unsubscribe()
|
||||
store.dispatch(api.util.invalidateTags(['Post']))
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { api } from './api'
|
||||
import { store } from './store'
|
||||
|
||||
store.dispatch(api.endpoints.getPosts.initiate())
|
||||
store.dispatch(api.util.invalidateTags(['Post']))
|
||||
```
|
||||
|
||||
Invalidation only refetches actively subscribed queries; if no component is using that cache entry, RTK Query drops it and fetches again next time it is needed.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/rtk-query/usage/automated-refetching.mdx
|
||||
|
||||
## References
|
||||
|
||||
- [Endpoint lifecycle details and cache tradeoffs](references/endpoint-lifecycle.md)
|
||||
36
frontend/node_modules/@reduxjs/toolkit/skills/manage-server-data/adopt-rtk-query/references/endpoint-lifecycle.md
generated
vendored
Normal file
36
frontend/node_modules/@reduxjs/toolkit/skills/manage-server-data/adopt-rtk-query/references/endpoint-lifecycle.md
generated
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Endpoint Lifecycle
|
||||
|
||||
## Invalidation rule
|
||||
|
||||
When a mutation invalidates tags:
|
||||
|
||||
- active subscribers refetch
|
||||
- inactive cache entries are removed
|
||||
- removed entries fetch again only when something subscribes later
|
||||
|
||||
That behavior is deliberate; invalidation is not a background "refresh everything" switch.
|
||||
|
||||
## Document cache tradeoff
|
||||
|
||||
RTK Query is a document cache, not a normalized entity graph cache.
|
||||
|
||||
Use RTK Query by default when:
|
||||
|
||||
- the data comes from request/response APIs
|
||||
- document caching is acceptable
|
||||
- tag invalidation and endpoint lifecycles solve the problem
|
||||
|
||||
Reach for a different tool when:
|
||||
|
||||
- the real requirement is a normalized graph cache
|
||||
- the stack already has a domain-specific normalized client that fits better
|
||||
|
||||
If normalized caching is mandatory and no better library is already in the stack, a slice plus thunk flow may be the fallback.
|
||||
|
||||
## Useful endpoint options
|
||||
|
||||
- `providesTags`: tell RTK Query what cache entries this query represents
|
||||
- `invalidatesTags`: tell RTK Query what a mutation dirties
|
||||
- `onQueryStarted`: optimistic and pessimistic updates tied to a request
|
||||
- `onCacheEntryAdded`: long-lived subscriptions such as streaming data
|
||||
- `keepUnusedDataFor`: how long inactive cache entries stay around
|
||||
364
frontend/node_modules/@reduxjs/toolkit/skills/model-redux-state/build-slices-and-selectors/SKILL.md
generated
vendored
Normal file
364
frontend/node_modules/@reduxjs/toolkit/skills/model-redux-state/build-slices-and-selectors/SKILL.md
generated
vendored
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
---
|
||||
name: model-redux-state/build-slices-and-selectors
|
||||
description: >
|
||||
Use this when authoring or refactoring slices with createSlice, selectors,
|
||||
create.asyncThunk, entity adapters, or lazy reducer injection. Covers
|
||||
Immer-backed mutation syntax, slice selectors, getSelectors, injectInto,
|
||||
withLazyLoadedSlices, and current RTK 2 slice patterns.
|
||||
type: core
|
||||
library: "@reduxjs/toolkit"
|
||||
library_version: "2.11.2"
|
||||
requires:
|
||||
- model-redux-state/design-state-ownership
|
||||
sources:
|
||||
- "reduxjs/redux-toolkit:docs/api/createSlice.mdx"
|
||||
- "reduxjs/redux-toolkit:docs/api/combineSlices.mdx"
|
||||
- "reduxjs/redux-toolkit:docs/api/createEntityAdapter.mdx"
|
||||
- "reduxjs/redux-toolkit:docs/usage/immer-reducers.md"
|
||||
- "reduxjs/redux-toolkit:docs/usage/migrating-rtk-2.md"
|
||||
- "reduxjs/redux:docs/style-guide/style-guide.md"
|
||||
---
|
||||
|
||||
# Build Slices And Selectors
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// file: src/app/createAppSlice.ts
|
||||
import { asyncThunkCreator, buildCreateSlice } from '@reduxjs/toolkit'
|
||||
|
||||
export const createAppSlice = buildCreateSlice({
|
||||
creators: { asyncThunk: asyncThunkCreator },
|
||||
})
|
||||
|
||||
// file: src/features/posts/postsSlice.ts
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import { createAppSlice } from '../../app/createAppSlice'
|
||||
|
||||
type PostsState = {
|
||||
items: { id: string; title: string; published: boolean }[]
|
||||
status: 'idle' | 'pending' | 'succeeded' | 'failed'
|
||||
}
|
||||
|
||||
const initialState: PostsState = {
|
||||
items: [],
|
||||
status: 'idle',
|
||||
}
|
||||
|
||||
export const postsSlice = createAppSlice({
|
||||
name: 'posts',
|
||||
initialState,
|
||||
reducers: (create) => ({
|
||||
postAdded: create.reducer<{ id: string; title: string }>((state, action) => {
|
||||
state.items.push({ ...action.payload, published: false })
|
||||
}),
|
||||
fetchPosts: create.asyncThunk(
|
||||
async () => {
|
||||
const response = await fetch('/api/posts')
|
||||
return (await response.json()) as { id: string; title: string; published: boolean }[]
|
||||
},
|
||||
{
|
||||
pending: (state) => {
|
||||
state.status = 'pending'
|
||||
},
|
||||
fulfilled: (state, action) => {
|
||||
state.status = 'succeeded'
|
||||
state.items = action.payload
|
||||
},
|
||||
rejected: (state) => {
|
||||
state.status = 'failed'
|
||||
},
|
||||
},
|
||||
),
|
||||
}),
|
||||
selectors: {
|
||||
selectPosts: (state) => state.items,
|
||||
selectPublishedPosts: createSelector(
|
||||
[(state: PostsState) => state.items],
|
||||
(items) => items.filter((post) => post.published),
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
export const { postAdded, fetchPosts } = postsSlice.actions
|
||||
export const { selectPosts, selectPublishedPosts } = postsSlice.selectors
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Use mutating logic inside slice reducers
|
||||
|
||||
```ts
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const todosSlice = createSlice({
|
||||
name: 'todos',
|
||||
initialState: [] as { id: string; text: string; done: boolean }[],
|
||||
reducers: {
|
||||
todoAdded(state, action: { payload: { id: string; text: string } }) {
|
||||
state.push({ ...action.payload, done: false })
|
||||
},
|
||||
todoToggled(state, action: { payload: { id: string } }) {
|
||||
const todo = state.find((item) => item.id === action.payload.id)
|
||||
if (todo) {
|
||||
todo.done = !todo.done
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Immer is the default inside `createSlice`; write the reducer logic directly instead of copying arrays and objects by hand.
|
||||
|
||||
### Define selectors in the slice when they belong to the slice
|
||||
|
||||
```ts
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const counterSlice = createSlice({
|
||||
name: 'counter',
|
||||
initialState: { value: 0 },
|
||||
reducers: {
|
||||
increment(state) {
|
||||
state.value += 1
|
||||
},
|
||||
},
|
||||
selectors: {
|
||||
selectValue: (state) => state.value,
|
||||
selectIsPositive: (state) => state.value > 0,
|
||||
},
|
||||
})
|
||||
|
||||
const { selectValue, selectIsPositive } = counterSlice.selectors
|
||||
```
|
||||
|
||||
Slice selectors keep state-location knowledge next to the slice.
|
||||
|
||||
### Use `create.asyncThunk` when the async lifecycle belongs to the slice
|
||||
|
||||
```ts
|
||||
import { asyncThunkCreator, buildCreateSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const createAppSlice = buildCreateSlice({
|
||||
creators: { asyncThunk: asyncThunkCreator },
|
||||
})
|
||||
|
||||
const usersSlice = createAppSlice({
|
||||
name: 'users',
|
||||
initialState: { items: [] as { id: string; name: string }[], status: 'idle' as 'idle' | 'pending' | 'failed' },
|
||||
reducers: (create) => ({
|
||||
fetchUsers: create.asyncThunk(
|
||||
async () => {
|
||||
const response = await fetch('/api/users')
|
||||
return (await response.json()) as { id: string; name: string }[]
|
||||
},
|
||||
{
|
||||
pending: (state) => {
|
||||
state.status = 'pending'
|
||||
},
|
||||
fulfilled: (state, action) => {
|
||||
state.status = 'idle'
|
||||
state.items = action.payload
|
||||
},
|
||||
rejected: (state) => {
|
||||
state.status = 'failed'
|
||||
},
|
||||
},
|
||||
),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Use this when the async lifecycle handlers naturally live with the slice; otherwise regular `createAsyncThunk` is still fine.
|
||||
|
||||
### Use entity adapters and lazy injection for scalable slices
|
||||
|
||||
```ts
|
||||
import {
|
||||
combineSlices,
|
||||
createEntityAdapter,
|
||||
createSlice,
|
||||
} from '@reduxjs/toolkit'
|
||||
|
||||
type Book = { bookId: string; title: string }
|
||||
|
||||
const booksAdapter = createEntityAdapter<Book>({
|
||||
selectId: (book) => book.bookId,
|
||||
})
|
||||
|
||||
const booksSlice = createSlice({
|
||||
name: 'books',
|
||||
initialState: booksAdapter.getInitialState(),
|
||||
reducers: {
|
||||
booksReceived: booksAdapter.setAll,
|
||||
},
|
||||
})
|
||||
|
||||
export interface LazyLoadedSlices {}
|
||||
|
||||
export const rootReducer =
|
||||
combineSlices().withLazyLoadedSlices<LazyLoadedSlices>()
|
||||
|
||||
declare module './rootReducer' {
|
||||
export interface LazyLoadedSlices {}
|
||||
}
|
||||
|
||||
const injectedBooksSlice = booksSlice.injectInto(rootReducer)
|
||||
|
||||
const selectors = booksAdapter.getSelectors(
|
||||
(state: ReturnType<typeof rootReducer.selector.original>) =>
|
||||
injectedBooksSlice.selectSlice(state),
|
||||
)
|
||||
```
|
||||
|
||||
Entity adapters standardize normalized collections, and `injectInto` lets a slice stay aware of its injected location.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### CRITICAL Using mutating logic outside slice reducers
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
type Todo = { id: string; text: string }
|
||||
|
||||
export function addTodo(todos: Todo[], todo: Todo) {
|
||||
todos.push(todo)
|
||||
return todos
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
type Todo = { id: string; text: string }
|
||||
|
||||
const todosSlice = createSlice({
|
||||
name: 'todos',
|
||||
initialState: [] as Todo[],
|
||||
reducers: {
|
||||
todoAdded(state, action: { payload: Todo }) {
|
||||
state.push(action.payload)
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Mutation syntax is only safe inside Immer-backed reducer contexts such as `createSlice` and `createReducer`.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/usage/immer-reducers.md
|
||||
|
||||
### HIGH Writing hand-written switch reducers as the default
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
export default function todosReducer(state = initialState, action: { type: string; payload?: Todo }) {
|
||||
switch (action.type) {
|
||||
case 'todos/todoAdded':
|
||||
return state.concat(action.payload as Todo)
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
const todosSlice = createSlice({
|
||||
name: 'todos',
|
||||
initialState,
|
||||
reducers: {
|
||||
todoAdded(state, action: { payload: Todo }) {
|
||||
state.push(action.payload)
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Hand-written reducers are an escape hatch for proven bottlenecks, not the normal thing an agent should generate in RTK code.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/usage/migrating-to-modern-redux.mdx
|
||||
|
||||
### HIGH Writing RTK 1.x object syntax for `extraReducers`
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const initialState = { items: [] as { id: string; title: string }[] }
|
||||
|
||||
const fetchPosts = createAsyncThunk('posts/fetch', async () => {
|
||||
const response = await fetch('/api/posts')
|
||||
return (await response.json()) as { id: string; title: string }[]
|
||||
})
|
||||
|
||||
const postsSlice = createSlice({
|
||||
name: 'posts',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: {
|
||||
[fetchPosts.fulfilled.type]: (state, action) => {
|
||||
state.items = action.payload
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const initialState = { items: [] as { id: string; title: string }[] }
|
||||
|
||||
const fetchPosts = createAsyncThunk('posts/fetch', async () => {
|
||||
const response = await fetch('/api/posts')
|
||||
return (await response.json()) as { id: string; title: string }[]
|
||||
})
|
||||
|
||||
const postsSlice = createSlice({
|
||||
name: 'posts',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(fetchPosts.fulfilled, (state, action) => {
|
||||
state.items = action.payload
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
RTK 2 removed the object form; agents trained on RTK 1.x still generate it.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/usage/migrating-rtk-2.md
|
||||
|
||||
### HIGH Assuming `entity.id` exists for every collection
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
type Book = { bookId: string; title: string }
|
||||
|
||||
const booksAdapter = createEntityAdapter<Book>()
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
type Book = { bookId: string; title: string }
|
||||
|
||||
const booksAdapter = createEntityAdapter<Book>({
|
||||
selectId: (book) => book.bookId,
|
||||
})
|
||||
```
|
||||
|
||||
Adapters default to `entity.id`; collections keyed by another field must provide `selectId`.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/api/createEntityAdapter.mdx
|
||||
|
||||
## References
|
||||
|
||||
- [Slice selectors, async creators, and lazy injection details](references/slice-patterns.md)
|
||||
59
frontend/node_modules/@reduxjs/toolkit/skills/model-redux-state/build-slices-and-selectors/references/slice-patterns.md
generated
vendored
Normal file
59
frontend/node_modules/@reduxjs/toolkit/skills/model-redux-state/build-slices-and-selectors/references/slice-patterns.md
generated
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Slice Patterns
|
||||
|
||||
## `getSelectors` for alternate mounting points
|
||||
|
||||
```ts
|
||||
const counterSlice = createSlice({
|
||||
name: 'counter',
|
||||
initialState: { value: 0 },
|
||||
reducers: {},
|
||||
selectors: {
|
||||
selectValue: (state) => state.value,
|
||||
},
|
||||
})
|
||||
|
||||
export const { selectValue } = counterSlice.getSelectors(
|
||||
(state: { customCounter: { value: number } }) => state.customCounter,
|
||||
)
|
||||
```
|
||||
|
||||
Use `getSelectors` when the slice is not mounted at its default `reducerPath`.
|
||||
|
||||
## `withLazyLoadedSlices` and `injectInto`
|
||||
|
||||
```ts
|
||||
import { combineSlices, createSlice, type WithSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const staticSlice = createSlice({
|
||||
name: 'static',
|
||||
initialState: { ready: true },
|
||||
reducers: {},
|
||||
})
|
||||
|
||||
export interface LazyLoadedSlices {}
|
||||
|
||||
export const rootReducer =
|
||||
combineSlices(staticSlice).withLazyLoadedSlices<LazyLoadedSlices>()
|
||||
|
||||
const lazySlice = createSlice({
|
||||
name: 'lazy',
|
||||
initialState: { value: 0 },
|
||||
reducers: {
|
||||
increment(state) {
|
||||
state.value += 1
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
declare module '../rootReducer' {
|
||||
export interface LazyLoadedSlices extends WithSlice<typeof lazySlice> {}
|
||||
}
|
||||
|
||||
export const injectedLazySlice = lazySlice.injectInto(rootReducer)
|
||||
```
|
||||
|
||||
This keeps RootState types aware of reducers that will be injected later.
|
||||
|
||||
## Selector caveat
|
||||
|
||||
If a selector depends on caller-specific arguments and must be memoized per caller, prefer a selector factory outside `createSlice.selectors`. `createSlice.selectors` gives you one selector instance, not a selector factory.
|
||||
322
frontend/node_modules/@reduxjs/toolkit/skills/model-redux-state/design-state-ownership/SKILL.md
generated
vendored
Normal file
322
frontend/node_modules/@reduxjs/toolkit/skills/model-redux-state/design-state-ownership/SKILL.md
generated
vendored
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
---
|
||||
name: model-redux-state/design-state-ownership
|
||||
description: >
|
||||
Use this when deciding whether data belongs in Redux, component state, router
|
||||
state, or another external source. Covers state ownership, authority
|
||||
boundaries, slice sizing, and when to move or split data as the app evolves.
|
||||
type: core
|
||||
library: "@reduxjs/toolkit"
|
||||
library_version: "2.11.2"
|
||||
sources:
|
||||
- "reduxjs/redux:docs/style-guide/style-guide.md"
|
||||
- "reduxjs/redux:docs/tutorials/essentials/part-2-app-structure.md"
|
||||
- "reduxjs/redux:docs/tutorials/essentials/part-4-using-data.md"
|
||||
---
|
||||
|
||||
# Design State Ownership
|
||||
|
||||
## Setup
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
import { useAppDispatch } from '../../app/hooks'
|
||||
|
||||
const postsSlice = createSlice({
|
||||
name: 'posts',
|
||||
initialState: [] as { id: string; title: string; content: string }[],
|
||||
reducers: {
|
||||
postAdded(
|
||||
state,
|
||||
action: { payload: { id: string; title: string; content: string } },
|
||||
) {
|
||||
state.push(action.payload)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { postAdded } = postsSlice.actions
|
||||
|
||||
export function AddPostForm() {
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
dispatch(postAdded({ id: 'p1', title, content }))
|
||||
}}
|
||||
>
|
||||
<input value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Keep editable form state local until the user commits it
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { useAppDispatch } from '../../app/hooks'
|
||||
import { profileSaved } from './profileSlice'
|
||||
|
||||
export function ProfileForm() {
|
||||
const [displayName, setDisplayName] = useState('Lenz')
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
dispatch(profileSaved({ displayName }))
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Prefer Redux for shared, durable app state, not every keystroke.
|
||||
|
||||
### Keep URL state with the router and combine it at the edge
|
||||
|
||||
```tsx
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useAppSelector } from '../../app/hooks'
|
||||
|
||||
type RootState = {
|
||||
posts: {
|
||||
items: { id: string; title: string; published: boolean }[]
|
||||
}
|
||||
}
|
||||
|
||||
const selectPosts = (state: RootState) => state.posts.items
|
||||
|
||||
const selectVisiblePosts = createSelector(
|
||||
[selectPosts, (_state: RootState, filter: string) => filter],
|
||||
(posts, filter) =>
|
||||
filter === 'published'
|
||||
? posts.filter((post) => post.published)
|
||||
: posts,
|
||||
)
|
||||
|
||||
export function PostsList() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const filter = searchParams.get('filter') ?? 'all'
|
||||
const posts = useAppSelector((state) => selectVisiblePosts(state, filter))
|
||||
|
||||
return <div>{posts.length}</div>
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Re-size slices when access patterns change
|
||||
|
||||
```ts
|
||||
import { combineReducers, createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState: { userId: null as string | null },
|
||||
reducers: {},
|
||||
})
|
||||
|
||||
const postsSlice = createSlice({
|
||||
name: 'posts',
|
||||
initialState: { items: [] as { id: string; title: string }[] },
|
||||
reducers: {},
|
||||
})
|
||||
|
||||
export const rootReducer = combineReducers({
|
||||
auth: authSlice.reducer,
|
||||
posts: postsSlice.reducer,
|
||||
})
|
||||
```
|
||||
|
||||
Revisit slice size over time; unrelated data should split apart, and data constantly stitched together in every component may belong closer together.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### MEDIUM Putting form editing state in Redux
|
||||
|
||||
Wrong:
|
||||
|
||||
```tsx
|
||||
import { useAppSelector } from '../../app/hooks'
|
||||
|
||||
const selectDraftTitle = (state: { draft: { title: string } }) => state.draft.title
|
||||
|
||||
const title = useAppSelector(selectDraftTitle)
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```tsx
|
||||
const [title, setTitle] = useState('')
|
||||
|
||||
<input value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||
```
|
||||
|
||||
Per-keystroke dispatching adds global complexity for data that usually lives in one component tree.
|
||||
|
||||
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
||||
|
||||
### HIGH Synchronizing router or URL state into Redux
|
||||
|
||||
Wrong:
|
||||
|
||||
```tsx
|
||||
import { useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useAppDispatch } from '../../app/hooks'
|
||||
|
||||
function PostsPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(filterChanged(searchParams.get('filter') ?? 'all'))
|
||||
}, [dispatch, searchParams])
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```tsx
|
||||
const filter = searchParams.get('filter') ?? 'all'
|
||||
const posts = useAppSelector((state) => selectVisiblePosts(state, filter))
|
||||
```
|
||||
|
||||
URL state already has an authoritative owner; duplicating it into Redux creates two sources of truth.
|
||||
|
||||
Source: maintainer interview
|
||||
|
||||
### HIGH Naming state after components
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { combineReducers } from '@reduxjs/toolkit'
|
||||
|
||||
const loginReducer = (state = { open: false }) => state
|
||||
const postsReducer = (state = [] as { id: string; title: string }[]) => state
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
loginScreen: loginReducer,
|
||||
postsList: postsReducer,
|
||||
})
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { combineReducers } from '@reduxjs/toolkit'
|
||||
|
||||
const authReducer = (state = { userId: null as string | null }) => state
|
||||
const postsReducer = (state = [] as { id: string; title: string }[]) => state
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
posts: postsReducer,
|
||||
})
|
||||
```
|
||||
|
||||
Store keys should describe data or domain concepts, not the current component tree.
|
||||
|
||||
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
||||
|
||||
### MEDIUM Letting slice boundaries fossilize
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
type Post = { id: string; title: string }
|
||||
type AppNotification = { id: string; message: string }
|
||||
|
||||
const appSlice = createSlice({
|
||||
name: 'app',
|
||||
initialState: {
|
||||
auth: { userId: null as string | null },
|
||||
posts: [] as Post[],
|
||||
notifications: [] as AppNotification[],
|
||||
},
|
||||
reducers: {},
|
||||
})
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
type Post = { id: string; title: string }
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState: { userId: null as string | null },
|
||||
reducers: {},
|
||||
})
|
||||
|
||||
const postsSlice = createSlice({
|
||||
name: 'posts',
|
||||
initialState: [] as Post[],
|
||||
reducers: {},
|
||||
})
|
||||
```
|
||||
|
||||
When unrelated data is welded together, every change point gets noisier; split or merge slices as actual access patterns demand.
|
||||
|
||||
Source: maintainer interview
|
||||
|
||||
### HIGH Blindly spreading payloads into state
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
const state = { id: '1', name: 'Lenz' }
|
||||
const action = { payload: { id: '2', name: 'Mark', ignored: true } }
|
||||
|
||||
userLoggedIn(state, action) {
|
||||
return { ...state, ...action.payload }
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
const state = { id: '1', name: 'Lenz' }
|
||||
const action = { payload: { id: '2', name: 'Mark', ignored: true } }
|
||||
|
||||
userLoggedIn(state, action) {
|
||||
state.id = action.payload.id
|
||||
state.name = action.payload.name
|
||||
}
|
||||
```
|
||||
|
||||
Reducers should own the slice shape instead of treating payloads as trusted state patches.
|
||||
|
||||
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
||||
|
||||
## References
|
||||
|
||||
- [State ownership heuristics](references/state-ownership.md)
|
||||
28
frontend/node_modules/@reduxjs/toolkit/skills/model-redux-state/design-state-ownership/references/state-ownership.md
generated
vendored
Normal file
28
frontend/node_modules/@reduxjs/toolkit/skills/model-redux-state/design-state-ownership/references/state-ownership.md
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# State Ownership Heuristics
|
||||
|
||||
## Choose the owner, then the tool
|
||||
|
||||
| Kind of state | Default owner | Typical tool |
|
||||
| --- | --- | --- |
|
||||
| Editable form fields | Component | `useState` |
|
||||
| Shared mutable app data | Redux | Slice state |
|
||||
| Server cache | RTK Query | `createApi` |
|
||||
| URL, pathname, search params | Router | Router APIs plus selector inputs |
|
||||
| Browser-only authority like `localStorage` | External source | Read at boundaries, then dispatch events |
|
||||
|
||||
## Good reasons to move data into Redux
|
||||
|
||||
- Multiple distant parts of the UI need the same mutable data.
|
||||
- You need time-travel debugging or a stable action history.
|
||||
- The reducer should own transitions because they mix old store state with new inputs.
|
||||
|
||||
## Reasons to keep data out of Redux
|
||||
|
||||
- Another system already owns it, such as the router.
|
||||
- It only matters during editing inside one component tree.
|
||||
- It is server cache and RTK Query fits the use case better.
|
||||
|
||||
## Re-evaluate slice size
|
||||
|
||||
- If data is constantly stitched together outside reducers, it may belong closer together.
|
||||
- If unrelated updates keep touching the same slice, split the slice by domain ownership.
|
||||
271
frontend/node_modules/@reduxjs/toolkit/skills/orchestrate-side-effects/handle-side-effects/SKILL.md
generated
vendored
Normal file
271
frontend/node_modules/@reduxjs/toolkit/skills/orchestrate-side-effects/handle-side-effects/SKILL.md
generated
vendored
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
---
|
||||
name: orchestrate-side-effects/handle-side-effects
|
||||
description: >
|
||||
Use this when choosing between RTK Query, createAsyncThunk, handwritten
|
||||
thunks, and createListenerMiddleware. Covers imperative versus reactive
|
||||
workflows, listener middleware setup, and keeping side effects out of
|
||||
reducers and UI components.
|
||||
type: core
|
||||
library: "@reduxjs/toolkit"
|
||||
library_version: "2.11.2"
|
||||
requires:
|
||||
- build-modern-redux-apps/redux-dataflow
|
||||
sources:
|
||||
- "reduxjs/redux-toolkit:docs/api/createAsyncThunk.mdx"
|
||||
- "reduxjs/redux-toolkit:docs/api/createListenerMiddleware.mdx"
|
||||
- "reduxjs/redux:docs/style-guide/style-guide.md"
|
||||
- "reduxjs/redux:docs/tutorials/essentials/part-5-async-logic.md"
|
||||
---
|
||||
|
||||
# Handle Side Effects
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
import {
|
||||
configureStore,
|
||||
createListenerMiddleware,
|
||||
createSlice,
|
||||
} from '@reduxjs/toolkit'
|
||||
|
||||
const docsSlice = createSlice({
|
||||
name: 'docs',
|
||||
initialState: { status: 'idle' as 'idle' | 'saved' },
|
||||
reducers: {
|
||||
saveStarted(state) {
|
||||
state.status = 'idle'
|
||||
},
|
||||
saveFinished(state) {
|
||||
state.status = 'saved'
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const listenerMiddleware = createListenerMiddleware()
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
docs: docsSlice.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
|
||||
export const startAppListening =
|
||||
listenerMiddleware.startListening.withTypes<RootState, AppDispatch>()
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Use RTK Query for server cache by default
|
||||
|
||||
```ts
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
|
||||
type Post = { id: string; title: string }
|
||||
|
||||
export const api = createApi({
|
||||
reducerPath: 'api',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
|
||||
tagTypes: ['Post'],
|
||||
endpoints: (build) => ({
|
||||
getPosts: build.query<Post[], void>({
|
||||
query: () => 'posts',
|
||||
providesTags: ['Post'],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
If the problem is server data that should be cached and re-used, start with RTK Query instead of a thunk.
|
||||
|
||||
### Use `createAsyncThunk` for imperative workflows
|
||||
|
||||
```ts
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
type Draft = { title: string }
|
||||
|
||||
export const draftSaved = createAsyncThunk(
|
||||
'drafts/save',
|
||||
async (draft: Draft) => {
|
||||
const response = await fetch('/api/drafts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(draft),
|
||||
})
|
||||
|
||||
return (await response.json()) as { id: string; title: string }
|
||||
},
|
||||
)
|
||||
|
||||
const draftsSlice = createSlice({
|
||||
name: 'drafts',
|
||||
initialState: { status: 'idle' as 'idle' | 'pending' | 'failed' },
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(draftSaved.pending, (state) => {
|
||||
state.status = 'pending'
|
||||
})
|
||||
.addCase(draftSaved.fulfilled, (state) => {
|
||||
state.status = 'idle'
|
||||
})
|
||||
.addCase(draftSaved.rejected, (state) => {
|
||||
state.status = 'failed'
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Use a thunk when you need one imperative async workflow with `dispatch` and `getState`.
|
||||
|
||||
### Use listener middleware for reactive workflows
|
||||
|
||||
```ts
|
||||
import { createListenerMiddleware, createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const docsSlice = createSlice({
|
||||
name: 'docs',
|
||||
initialState: { status: 'idle' as 'idle' | 'saved' },
|
||||
reducers: {
|
||||
saveFinished(state) {
|
||||
state.status = 'saved'
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const notificationsSlice = createSlice({
|
||||
name: 'notifications',
|
||||
initialState: [] as string[],
|
||||
reducers: {
|
||||
notificationQueued(state, action: { payload: string }) {
|
||||
state.push(action.payload)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const listenerMiddleware = createListenerMiddleware()
|
||||
|
||||
listenerMiddleware.startListening({
|
||||
actionCreator: docsSlice.actions.saveFinished,
|
||||
effect: async (_action, listenerApi) => {
|
||||
listenerApi.dispatch(
|
||||
notificationsSlice.actions.notificationQueued('Document saved'),
|
||||
)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Listeners fit workflows that react to future actions or state changes over time instead of driving one imperative request from a single callsite.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### CRITICAL Running side effects inside reducers
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
const todosSlice = createSlice({
|
||||
name: 'todos',
|
||||
initialState: [] as { id: string }[],
|
||||
reducers: {
|
||||
todoSaved(state, action: { payload: { id: string } }) {
|
||||
fetch('/api/todos', { method: 'POST' })
|
||||
state.push(action.payload)
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit'
|
||||
|
||||
const todoSaved = createAsyncThunk('todos/save', async (todo: { id: string }) => {
|
||||
await fetch('/api/todos', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(todo),
|
||||
})
|
||||
return todo
|
||||
})
|
||||
```
|
||||
|
||||
Reducers must stay pure even when Immer is available.
|
||||
|
||||
Source: reduxjs/redux:docs/style-guide/style-guide.md
|
||||
|
||||
### HIGH Using thunks to watch future state changes
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
export const waitForSave = () => async (
|
||||
_dispatch: unknown,
|
||||
getState: () => { docs: { status: string } },
|
||||
) => {
|
||||
while (getState().docs.status !== 'saved') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
startAppListening({
|
||||
predicate: (_action, currentState) => currentState.docs.status === 'saved',
|
||||
effect: async () => {
|
||||
console.log('Document saved')
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Polling inside thunks fights the architecture; listener middleware is the reactive tool.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/api/createListenerMiddleware.mdx
|
||||
|
||||
### HIGH Appending listener middleware after the default checks
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'
|
||||
|
||||
const reducer = (state = { ready: true }) => state
|
||||
const listenerMiddleware = createListenerMiddleware()
|
||||
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(listenerMiddleware.middleware),
|
||||
})
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'
|
||||
|
||||
const reducer = (state = { ready: true }) => state
|
||||
const listenerMiddleware = createListenerMiddleware()
|
||||
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
||||
})
|
||||
```
|
||||
|
||||
Listener add and remove actions may carry functions, so the listener middleware needs to run before serializability checks.
|
||||
|
||||
Source: reduxjs/redux-toolkit:docs/api/createListenerMiddleware.mdx
|
||||
|
||||
## References
|
||||
|
||||
- [Listener helpers and decision heuristics](references/listener-workflows.md)
|
||||
34
frontend/node_modules/@reduxjs/toolkit/skills/orchestrate-side-effects/handle-side-effects/references/listener-workflows.md
generated
vendored
Normal file
34
frontend/node_modules/@reduxjs/toolkit/skills/orchestrate-side-effects/handle-side-effects/references/listener-workflows.md
generated
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Listener Workflows
|
||||
|
||||
## Decision table
|
||||
|
||||
| Need | Reach for |
|
||||
| --- | --- |
|
||||
| Cached server data | RTK Query |
|
||||
| One imperative async workflow with `dispatch` / `getState` | `createAsyncThunk` or a thunk |
|
||||
| React to later actions or state transitions | `createListenerMiddleware` |
|
||||
|
||||
A good app often mixes imperative and reactive workflows. The split is by job, not by ideology.
|
||||
|
||||
## Useful listener helpers
|
||||
|
||||
- `predicate`: react to any action when a state condition becomes true
|
||||
- `condition`: wait until a condition becomes true before continuing
|
||||
- `take`: wait for the next matching action
|
||||
- `cancelActiveListeners`: cancel older instances of the same workflow
|
||||
- `fork`: start a child task
|
||||
|
||||
## Example: cancel stale work
|
||||
|
||||
```ts
|
||||
startAppListening({
|
||||
actionCreator: searchRequested,
|
||||
effect: async (action, listenerApi) => {
|
||||
listenerApi.cancelActiveListeners()
|
||||
await listenerApi.delay(250)
|
||||
listenerApi.dispatch(searchStarted(action.payload))
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
This is the kind of long-lived reactive behavior that does not fit a thunk well.
|
||||
Loading…
Add table
Add a link
Reference in a new issue