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:
jtricerolph 2026-07-20 14:37:36 +00:00
parent cae411eae7
commit 1c411e402e
19809 changed files with 1962608 additions and 97 deletions

View 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)

View 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.

View 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

View 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

View 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

View 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)

View 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

View 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)

View 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.

View 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)

View 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.

View 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)

View 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.