Skip to content

Commit 13407f3

Browse files
ryaneggzclaude
andauthored
feat: eliminate useEffect anti-patterns — TanStack Query + declarative patterns (#922) (#923)
* feat: eliminate useEffect anti-patterns with TanStack Query and declarative patterns (#922) Phase 0-2 of the useEffect refactoring initiative: - Install TanStack Query v5, create QueryProvider, useMountEffect, queryKeys factory - Replace fetch-on-mount effects in 6 settings components with useQuery - Refactor useAuth, useModel, useAgent hooks to use useQuery/useMountEffect - Remove redundant useEffectUpdateAssistantId (getMetadata already sets assistant_id) - Remove state-sync effect in useAgent (model applied at submission time) - Remove debug console.log effect in useThread - Add test utilities for QueryClientProvider wrapping Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: convert state-sync effects to derived values, add useMountEffect to contexts (#922) Phase 3-5 of useEffect refactoring: - ToolTimelineItem: derive isExpanded from initial message.artifact instead of effect - FileEditorPanel: derive isTreeCollapsed from isMobile, derive effectiveShowPreview instead of effect-based reset, remove isRecording state sync (use prop directly), inline latestEditorValueRef assignment instead of effect - ChatContext: convert loadPersistentContextFiles mount effect to useMountEffect - AppContext: convert fetchAppVersion mount effect to useMountEffect Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * fix: address code review findings — restore id reactivity, clean up dead code (#922) - useAgent: restore useEffect([id]) for useEffectGetAgent (pages may stay mounted while agentId changes, useMountEffect lost reactivity) - SandboxSettings: move mcpUrl initialization from render body to useEffect to avoid synchronous setState during render - useChat: fully remove useEffectUpdateAssistantId (stub + type + return) - Remove all useEffectUpdateAssistantId consumer calls from 5 pages and 2 test files — assistant_id is set at submission time in getMetadata() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * fix: remove unused imports flagged by tsc build (#922) - SandboxSettings: remove unused DEFAULT_SANDBOX import - useAgent: remove unused model destructure (model sync effect was removed) - useChat: remove unused useEffect import (last usage was useEffectUpdateAssistantId) - useChatPayloadModel.test: remove unused waitFor import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * fix: resolve nested button DOM nesting warning in FileEditorPanel tabs (#922) Changed the file tab element from <button> to <div role="tab"> to avoid invalid HTML nesting — the tab contained a child <button> for closing, which triggered React's validateDOMNesting warning. Added tabIndex, aria-selected, keyboard handling, and cursor-pointer for accessibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> --------- Signed-off-by: ryaneggz <kre8mymedia@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8971a34 commit 13407f3

38 files changed

Lines changed: 969 additions & 685 deletions
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
# Plan: Eliminate Direct `useEffect` — Adopt Declarative React Patterns
2+
3+
## Context
4+
5+
Based on [this tweet](https://x.com/alvinsng/status/2033969062834045089) ("Why we banned React's useEffect"), we're refactoring the Orchestra frontend to eliminate direct `useEffect` calls in favor of 5 declarative patterns:
6+
7+
1. **Derive state, don't sync it**`useMemo` / inline calculation instead of effect-based state sync
8+
2. **Use data-fetching libraries** — TanStack Query instead of manual fetch-in-effect
9+
3. **Event handlers, not effects** — handle actions in click/submit handlers directly
10+
4. **`useMountEffect` for external sync** — semantic wrapper for one-time DOM/third-party setup
11+
5. **Reset with `key`, not choreography** — React `key` prop to force clean remounts
12+
13+
**Current state**: ~200+ `useEffect` calls across 83 files. No data-fetching library. A non-standard `useEffectXxx()` wrapper pattern is used across 13+ hooks. After all phases, ~120 legitimate effects (DOM listeners, scroll, animation) remain, wrapped in `useMountEffect` or explicitly commented.
14+
15+
---
16+
17+
## Phase 0: Foundation (1 PR, no behavioral changes)
18+
19+
**Goal**: Install TanStack Query, create utility hooks, establish lint guidance.
20+
21+
### 0A. Install TanStack Query v5
22+
- **File**: `frontend/package.json`
23+
- Add `@tanstack/react-query` and `@tanstack/react-query-devtools`
24+
25+
### 0B. Create QueryClient + Provider
26+
- **Create**: `frontend/src/lib/queryClient.ts` — singleton with defaults (`staleTime: 5min`, `retry: 1`, `refetchOnWindowFocus: false`)
27+
- **Create**: `frontend/src/providers/QueryProvider.tsx` — wraps `<QueryClientProvider>` + devtools
28+
- **Modify**: App root to wrap with `QueryProvider` (outermost layer)
29+
30+
### 0C. Create `useMountEffect` hook
31+
- **Create**: `frontend/src/hooks/useMountEffect.ts`
32+
- Thin wrapper: `useEffect(fn, [])` with centralized eslint-disable comment
33+
- Will replace ~25-30 empty-dependency effects
34+
35+
### 0D. Create query key factory
36+
- **Create**: `frontend/src/lib/queryKeys.ts`
37+
- Centralized keys: `user`, `settings`, `models`, `agents.*`, `threads.*`, `projects`, `memories`, `tokens`, `schedules.*`, `sandboxHealth`
38+
39+
### Verification
40+
- App boots with QueryProvider wrapping
41+
- `npm run test` passes with no regressions
42+
43+
---
44+
45+
## Phase 1: Settings & Leaf Components (1 PR, low risk)
46+
47+
**Goal**: Replace simplest "fetch on mount" effects with `useQuery`. Isolated components, no downstream consumers.
48+
49+
### Files to modify (replace `useEffect(() => fetch, [])` with `useQuery`):
50+
| File | Current Pattern | Replacement |
51+
|------|----------------|-------------|
52+
| `src/components/settings/DefaultModelSettings.tsx:39` | `useEffect → getSettings().then(setDefaultModel)` | `useQuery` + derive `defaultModel` from query data |
53+
| `src/components/settings/TimezoneSettings.tsx:36` | `useEffect → getSettings().then(setTimezone)` | `useQuery` + derive timezone |
54+
| `src/components/settings/SandboxSettings.tsx:68` | `useEffect → getSettings() + getProviderKeys()` | `useQuery` for each |
55+
| `src/components/settings/ApiTokensSettings.tsx:40` | `useEffect → fetch tokens` | `useQuery` + `useMutation` for create/delete |
56+
| `src/components/settings/MemorySettings.tsx:70` | `useEffect → fetchMemories` | `useQuery` with search params as key deps |
57+
| `src/components/settings/UserApiKeysSettings.tsx:60` | `useEffect → fetch provider keys` | `useQuery` |
58+
| `src/hooks/useSandboxHealth.ts:52` | `useEffect → check health on URL change` | `useQuery` with `enabled: !!mcpSandboxUrl` |
59+
| `src/hooks/useModelsList.ts` | `useEffect → listModels()` | `useQuery` returning `{ models, isLoading }` |
60+
61+
### Verification
62+
- Settings page loads all settings correctly
63+
- Sandbox health indicator works
64+
- `npm run test` passes
65+
66+
---
67+
68+
## Phase 2: Hook Data-Fetching Layer (1-2 PRs, medium risk)
69+
70+
**Goal**: Replace the `useEffectXxx()` wrapper pattern in shared hooks. This is the biggest structural change — these hooks are consumed by every page.
71+
72+
### 2A. `useAuth.tsx` — Replace `useEffectGetUser`
73+
- **File**: `src/hooks/useAuth.tsx:15-24`
74+
- Replace nested `useEffectGetUser()` function with `useQuery({ queryKey: queryKeys.user(), queryFn: ... })`
75+
- Remove `useState` for `user` — derive from query
76+
77+
### 2B. `useModel.tsx` — Replace `useModelsEffect`
78+
- **File**: `src/hooks/useModel.tsx`
79+
- Replace `useModelsEffect()` with internal `useQuery`
80+
- **Breaking**: Remove `useModelsEffect` from return type
81+
- **Consumer updates** (remove `useModelsEffect()` calls):
82+
- `src/pages/threads/ThreadPage.tsx:72`
83+
- `src/pages/chat/chat-v2.tsx:40`
84+
- `src/pages/agents/edit.tsx:33`
85+
- `src/pages/agents/thread.tsx:24`
86+
- `src/components/settings/DefaultModelSettings.tsx:37`
87+
88+
### 2C. `useAgent.ts` — Replace `useEffectGetAgents` + fix state-sync
89+
- **File**: `src/hooks/useAgent.ts`
90+
- Replace `useEffectGetAgents()`, `useEffectGetPublicAgents()`, `useEffectGetAgent()` with `useQuery` calls
91+
- **Remove state-sync effect at line 44-48**: The `model` sync (`setAgent({ ...agent, model })`) is unnecessary — `useChat.getMetadata` already reads `model` at submission time
92+
- **Consumer updates** (remove `useEffectGetAgents()` calls):
93+
- `src/pages/threads/ThreadPage.tsx:73`
94+
- `src/pages/chat/chat-v2.tsx:41`
95+
- `src/pages/agents/edit.tsx:22`
96+
- `src/pages/agents/thread.tsx:42`
97+
- `src/pages/agents/index.tsx:69`
98+
99+
### 2D. `useThread.ts` — Replace thread data fetching
100+
- **File**: `src/hooks/useThread.ts`
101+
- Remove debug `console.log` effect at line 67-73
102+
- Replace `useListThreadsEffect`, `useListCheckpointsEffect`, `useLoadThreadEffect` with query-based hooks
103+
- Consider `useInfiniteQuery` for `loadMoreThreads` pagination
104+
- **Consumer updates** (remove wrapper calls from all pages):
105+
- `src/pages/threads/ThreadPage.tsx:75-76`
106+
- `src/pages/chat/chat-v2.tsx`
107+
- `src/pages/agents/thread.tsx`
108+
109+
### 2E. `useChat.ts` — Remove `useEffectUpdateAssistantId`
110+
- **File**: `src/hooks/useChat.ts:846-859`
111+
- This effect syncs `agent.id` into `metadata.assistant_id` — redundant since `getMetadata()` already reads `agent.id` at submission time
112+
- Remove effect + remove from return type
113+
- **Consumer updates**: Remove `useEffectUpdateAssistantId()` calls from all pages
114+
115+
### Verification
116+
- Chat with an agent (send message, receive streaming response)
117+
- Switch between threads
118+
- Agent selection and listing works
119+
- Thread pagination works
120+
- `npm run test` passes
121+
122+
---
123+
124+
## Phase 3: State-Sync & Key-Reset Effects (1-2 PRs, medium risk)
125+
126+
**Goal**: Convert state-sync effects to derived values, key-reset effects to React `key` props.
127+
128+
### 3A. ThreadPage key-reset pattern
129+
- **File**: `src/pages/threads/ThreadPage.tsx:94-115`
130+
- Current: effect resets 5 state values when `threadId` changes
131+
- **Fix**: Extract thread content into `<ThreadContent key={threadId} />` — React unmounts/remounts on key change, naturally resetting state
132+
133+
### 3B. Agent edit/thread page resets
134+
- **File**: `src/pages/agents/edit.tsx:59-71` — reset agent state on mount/unmount
135+
- **File**: `src/pages/agents/thread.tsx:80-92` — similar pattern
136+
- **Fix**: Use `key={agentId}` on content wrapper + `useMountEffect` for cleanup-only logic
137+
138+
### 3C. FileEditorPanel state-sync effects
139+
- **File**: `src/components/panels/FileEditorPanel.tsx`
140+
- Line 158: `isMobile → setIsTreeCollapsed` — derive directly or initialize with `useState(isMobile)`
141+
- Line 225: reset editor on file change — use `key={selectedFile}` on MonacoEditor
142+
- Line 248: reset preview on file change — derive `canPreview` as computed value
143+
- Line 260: `setIsRecording(isRecordingInProgress)` — remove local state, use prop directly
144+
145+
### 3D. ToolTimelineItem auto-expand
146+
- **File**: `src/components/timeline/ToolTimelineItem.tsx:123`
147+
- Replace `useEffect(() => { if (artifact) setIsExpanded(true) })` with `useState(!!message.artifact)`
148+
149+
### Verification
150+
- Navigate between threads — state resets correctly
151+
- File editor panel behavior preserved on mobile/desktop
152+
- Agent edit page initializes and cleans up properly
153+
- `npm run test` passes
154+
155+
---
156+
157+
## Phase 4: ChatContext Effect Chains (1 PR, high risk)
158+
159+
**Goal**: Break the complex effect chains in ChatContext.tsx — the highest-risk area.
160+
161+
### File: `src/context/ChatContext.tsx`
162+
163+
| Lines | Current | Replacement |
164+
|-------|---------|-------------|
165+
| 534-536 | `loadPersistentContextFiles` on mount | `useMountEffect(loadPersistentContextFiles)` |
166+
| 538-560 | Sync visible workspace files (7 deps) | `useMemo` for `visibleWorkspaceFiles` + controlled sync |
167+
| 562-623 | `filesMap` → source classification | Push classification into SSE handler in `useChat`, or keep with clear comment |
168+
| 629-668 | `fileSystem``submissionFiles` | `useMemo` — compute at submission time, remove state |
169+
| 672-676 | Clear queue on message reset | Call `clearQueue()` directly in `clearMessages()` |
170+
| 785-836 | Autosave with debounce | Extract to custom `useAutoSave` hook |
171+
| 838-853 | Post-streaming flush | Merge into `useAutoSave` hook |
172+
173+
### Verification
174+
- Create/edit files in file editor — files persist across sessions
175+
- Stream a response with file artifacts — files appear correctly
176+
- Autosave fires after editing, suppressed during streaming
177+
- Switch threads — files reset properly
178+
- `npm run test` passes
179+
180+
---
181+
182+
## Phase 5: Remaining Effects & Cleanup (1-2 PRs, low-medium risk)
183+
184+
### 5A. Convert mount-only effects to `useMountEffect`
185+
- `src/context/AppContext.tsx:20` — fetch app version
186+
- `src/context/ThemeContext.tsx:34` — apply theme classes
187+
- `src/context/OnboardingContext.tsx:43` — auth polling (convert to `useQuery` with `refetchInterval`)
188+
- `src/hooks/useDocumentTitle.ts` — title management
189+
- `src/embed/EmbedWidget.tsx:49,54,61` — auto-scroll, cleanup, focus
190+
- `src/pages/OAuthCallback.tsx:19` — token exchange
191+
192+
### 5B. Move event-handler effects to actual handlers
193+
- `src/pages/prompts/index.tsx:23` — clear search params (move to navigation handler)
194+
- `src/pages/agents/index.tsx:78` — clear messages (move to mount via `useMountEffect`)
195+
196+
### 5C. Fix `useMessageQueue.ts` effect chains
197+
- Lines 246-295: Move `processNext()` calls into stream completion handler and `setEditingId` handler respectively
198+
199+
### 5D. Fix `useServerHook.ts` state-sync effects
200+
- `useDefaultServerConfigEffect` → derive default code with `useMemo`
201+
- `useJsonValidationEffect` → derive with `useMemo`
202+
- `useFormHandlerEffect` → derive JSON from form state with `useMemo`
203+
204+
### Verification
205+
- All pages load without errors
206+
- Onboarding flow works
207+
- OAuth callback completes
208+
- Schedule executions auto-refresh
209+
- `npm run test` passes
210+
211+
---
212+
213+
## Phase 6: Lint Enforcement (1 PR)
214+
215+
- Add ESLint rule to warn on direct `useEffect` import from `react`
216+
- Allow exceptions for files with legitimate DOM effects (Monaco, voice visualizer, Mermaid)
217+
- Document patterns in `frontend/CLAUDE.md` under a new "Effect Patterns" section
218+
219+
---
220+
221+
## Summary
222+
223+
| Phase | PRs | Effects Eliminated | Risk | Key Files |
224+
|-------|-----|-------------------|------|-----------|
225+
| 0 | 1 | 0 (foundation) | Low | package.json, new files |
226+
| 1 | 1 | ~15 | Low | settings components, leaf hooks |
227+
| 2 | 1-2 | ~40 | Medium | useAuth, useModel, useAgent, useThread, useChat |
228+
| 3 | 1-2 | ~25 | Medium | ThreadPage, FileEditorPanel, agent pages |
229+
| 4 | 1 | ~8 | High | ChatContext.tsx |
230+
| 5 | 1-2 | ~30 | Low-Med | Contexts, remaining pages/components |
231+
| 6 | 1 | 0 (enforcement) | Low | ESLint config, CLAUDE.md |
232+
233+
**Total**: ~118 effects eliminated across 6-9 PRs. ~80-120 legitimate effects remain (DOM listeners, scroll, animation, third-party integrations) — these use `useMountEffect` or have explicit justification comments.
234+
235+
### Parallel Work Opportunities
236+
- Phases 1 and 3 can be worked on in parallel (different files)
237+
- Phase 4 must follow Phase 2 (ChatContext consumes hooks refactored in Phase 2)
238+
- Phase 6 follows all other phases
239+
240+
### Testing Strategy (all phases)
241+
- `npm run test` after every PR
242+
- Manual smoke test: send a chat message, switch threads, edit agent, open settings
243+
- For Phase 4 (ChatContext): write characterization tests before modifying

0 commit comments

Comments
 (0)