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