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