Skip to content

Commit 05d15f2

Browse files
authored
Merge pull request #424 from Mng-dev-ai/refactor/useeffect-discipline
Reduce useEffect usage with useMountEffect and ref-based resets
2 parents d5c91a8 + b87f155 commit 05d15f2

26 files changed

Lines changed: 175 additions & 152 deletions

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@
114114
- The `components/chat/tools/` directory is exclusively for tool components (one per tool type) — helper modals, dialogs, and detail views triggered by tools belong in `components/chat/` or a relevant feature folder, not in `tools/`
115115
- Shared UI components used by 2+ feature areas belong in `components/ui/shared/` — do not place them loose in a feature folder just because the first consumer lives there
116116

117+
### useEffect Discipline
118+
- Never call `useEffect` directly for mount-only effects — use `useMountEffect()` from `hooks/useMountEffect.ts` (`useEffect(fn, [])` wrapper) to make intent explicit and enable lint enforcement
119+
- Never use `useEffect` to derive state from other state or props — if state is always computable from existing values, use inline computation or `useMemo`; `useEffect(() => setX(f(y)), [y])` causes an unnecessary extra render cycle
120+
- When state must reset on a prop/ID change, use a ref-based render check instead of `useEffect` — pattern: `const prevRef = useRef(prop); if (prevRef.current !== prop) { prevRef.current = prop; setState(initial); }` — this runs synchronously during render and avoids the effect-induced double render
121+
- Distinguish "derived state" from "form state initialization" — if local state is a copy of server data that the user then edits independently (e.g., secrets form, settings form), syncing it via `useEffect` on query data change is the correct pattern; do not convert these to inline derivation, as the local state intentionally diverges from the source
122+
117123
### Component Variants
118124
- Create explicit variant components instead of one component with many boolean modes (e.g., `ThreadComposer`, `EditComposer` instead of `<Composer isThread isEditing />`)
119125
- Use `children` for composing static structure; use render props only when the parent needs to pass data back to the child (e.g., `renderItem` in lists)
@@ -139,6 +145,7 @@
139145
- Do not wrap trivial expressions in `useMemo` (e.g., `useMemo(() => x || [], [x])`) — use direct expressions (`x ?? []`)
140146
- Hoist regex patterns to module-level constants — never create RegExp inside loops or frequently-called functions
141147
- Prefer single-pass iteration (`.reduce()`) over chained `.filter().map()` in render paths
148+
- Keep `useEffect` for external system subscriptions and DOM side effects — keyboard shortcuts, resize observers, WebSocket lifecycle, scroll-into-view, and focus management require post-render timing and cleanup; do not convert these to ref-based render checks
142149

143150
### Async Patterns
144151
- Use `Promise.all()` for independent async operations (e.g., multiple `queryClient.invalidateQueries()` calls)

frontend/src/App.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { BrowserRouter, Routes, Route } from 'react-router-dom';
22
import { useEffect, useMemo, useState, Suspense, lazy } from 'react';
3+
import { useMountEffect } from '@/hooks/useMountEffect';
34
import { Layout } from '@/components/layout/Layout';
45
import { Toaster } from 'react-hot-toast';
56
import toast from 'react-hot-toast';
@@ -184,7 +185,7 @@ export default function App() {
184185

185186
useGlobalStream({ enabled: authHydrated && desktopReady });
186187

187-
useEffect(() => {
188+
useMountEffect(() => {
188189
let cancelled = false;
189190

190191
authStorage
@@ -201,7 +202,7 @@ export default function App() {
201202
return () => {
202203
cancelled = true;
203204
};
204-
}, []);
205+
});
205206

206207
useEffect(() => {
207208
document.body.classList.remove('light', 'dark');
@@ -213,7 +214,7 @@ export default function App() {
213214
}
214215
}, [resolvedTheme]);
215216

216-
useEffect(() => {
217+
useMountEffect(() => {
217218
if (!isTauri()) return;
218219

219220
let cancelled = false;
@@ -243,18 +244,18 @@ export default function App() {
243244
return () => {
244245
cancelled = true;
245246
};
246-
}, []);
247+
});
247248

248-
useEffect(() => {
249+
useMountEffect(() => {
249250
if (import.meta.env.DEV || !isTauri()) return;
250251

251252
checkDesktopUpdate().catch((error) => {
252253
console.error('Desktop updater check failed:', error);
253254
});
254-
}, []);
255+
});
255256

256257
// Open external links in the system browser — Tauri doesn't handle target="_blank" natively
257-
useEffect(() => {
258+
useMountEffect(() => {
258259
if (!isTauri()) return;
259260

260261
let openUrl: ((url: string) => Promise<void>) | null = null;
@@ -276,7 +277,7 @@ export default function App() {
276277

277278
document.addEventListener('click', handler);
278279
return () => document.removeEventListener('click', handler);
279-
}, []);
280+
});
280281

281282
if (desktopError) {
282283
return (

frontend/src/components/chat/chat-window/Chat.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,17 +156,19 @@ export const Chat = memo(function Chat() {
156156
const lastScrollTopRef = useRef<number | null>(null);
157157
const isAtBottomRef = useRef(true);
158158
const prevScrollHeightRef = useRef<number | null>(null);
159+
const prevChatIdForScrollRef = useRef(chatId);
159160

160161
const [showScrollButton, setShowScrollButton] = useState(false);
161162

162-
useEffect(() => {
163+
if (prevChatIdForScrollRef.current !== chatId) {
164+
prevChatIdForScrollRef.current = chatId;
163165
hasInitializedToBottomRef.current = false;
164166
topPaginationArmedRef.current = false;
165167
lastScrollTopRef.current = null;
166168
isAtBottomRef.current = true;
167169
prevScrollHeightRef.current = null;
168170
setShowScrollButton(false);
169-
}, [chatId]);
171+
}
170172

171173
const fetchNextPageRef = useRef(fetchNextPage);
172174
const hasNextPageRef = useRef(hasNextPage);

frontend/src/components/chat/chat-window/ChatSessionOrchestrator.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,11 @@ export function ChatSessionOrchestrator({
177177
hasFetchedMessages,
178178
]);
179179

180-
useEffect(() => {
180+
const prevChatIdForPromptRef = useRef(chatId);
181+
if (prevChatIdForPromptRef.current !== chatId) {
182+
prevChatIdForPromptRef.current = chatId;
181183
setInitialPromptSent(false);
182-
}, [chatId, setInitialPromptSent]);
184+
}
183185

184186
const chatSessionState = useMemo<ChatSessionState>(
185187
() => ({

frontend/src/components/chat/chat-window/ThinkingIndicator.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
import { memo, useState, useEffect, useRef } from 'react';
1+
import { memo, useState, useRef } from 'react';
2+
import { useMountEffect } from '@/hooks/useMountEffect';
23

34
export const ThinkingIndicator = memo(function ThinkingIndicator() {
45
const [elapsed, setElapsed] = useState(0);
56
const startTime = useRef(Date.now());
67

7-
useEffect(() => {
8+
useMountEffect(() => {
89
const interval = setInterval(() => {
910
setElapsed(Math.floor((Date.now() - startTime.current) / 1000));
1011
}, 1000);
1112

1213
return () => clearInterval(interval);
13-
}, []);
14+
});
1415

1516
return (
1617
<div className="animate-fade-in px-4 pb-1.5 sm:px-6 sm:pb-2">

frontend/src/components/chat/message-input/Textarea.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type CSSProperties,
77
type Ref,
88
} from 'react';
9+
import { useMountEffect } from '@/hooks/useMountEffect';
910
import { useIsMobile } from '@/hooks/useIsMobile';
1011

1112
const THIN_SCROLLBAR_STYLE: CSSProperties = { scrollbarWidth: 'thin' };
@@ -55,13 +56,13 @@ export function Textarea({
5556
}
5657
}, [isLoading, isMobile, textareaRef]);
5758

58-
useEffect(() => {
59+
useMountEffect(() => {
5960
return () => {
6061
if (debounceTimerRef.current) {
6162
clearTimeout(debounceTimerRef.current);
6263
}
6364
};
64-
}, []);
65+
});
6566

6667
const debouncedCursorChange = useCallback(
6768
(position: number) => {

frontend/src/components/chat/tools/AgentTool.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { use, useMemo, useState, useEffect, lazy, Suspense } from 'react';
1+
import React, { use, useMemo, useState, useRef, lazy, Suspense } from 'react';
22
import { Bot, Maximize2 } from 'lucide-react';
33
import type { ToolAggregate } from '@/types/tools.types';
44
import { ToolCard } from './common/ToolCard';
@@ -18,15 +18,17 @@ export const AgentTool: React.FC<AgentToolProps> = ({ tool }) => {
1818
const [resultExpanded, setResultExpanded] = useState(false);
1919
const [toolsExpanded, setToolsExpanded] = useState(false);
2020
const [modalOpen, setModalOpen] = useState(false);
21+
const prevToolIdRef = useRef(tool.id);
2122

2223
const siblingAgents = use(AgentToolsContext);
2324

24-
useEffect(() => {
25+
if (prevToolIdRef.current !== tool.id) {
26+
prevToolIdRef.current = tool.id;
2527
setPromptExpanded(false);
2628
setResultExpanded(false);
2729
setToolsExpanded(false);
2830
setModalOpen(false);
29-
}, [tool.id]);
31+
}
3032

3133
const prompt = tool.input?.prompt as string | undefined;
3234
const description = tool.input?.description as string | undefined;

frontend/src/components/editor/editor-view/View.tsx

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ export const View = memo(function View({
3131
const previousFileRef = useRef<FileStructure | null>(null);
3232
const [showPreview, setShowPreview] = useState(false);
3333
const [currentContent, setCurrentContent] = useState('');
34-
const [error, setError] = useState<string | null>(null);
3534
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
3635
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
3736
const monacoRef = useRef<typeof monaco | null>(null);
@@ -76,17 +75,11 @@ export const View = memo(function View({
7675
}
7776
}, [selectedFile, fileContentData]);
7877

79-
useEffect(() => {
80-
if (fileContentError) {
81-
const errorMessage =
82-
fileContentError instanceof Error
83-
? fileContentError.message
84-
: 'Failed to load file content';
85-
setError(errorMessage);
86-
} else {
87-
setError(null);
88-
}
89-
}, [fileContentError]);
78+
const error = fileContentError
79+
? fileContentError instanceof Error
80+
? fileContentError.message
81+
: 'Failed to load file content'
82+
: null;
9083

9184
useEffect(() => {
9285
if (!selectedFile) {
@@ -154,21 +147,25 @@ export const View = memo(function View({
154147
);
155148

156149
const [isPreviewFullscreen, setIsPreviewFullscreen] = useState(false);
150+
const prevShowPreviewRef = useRef(showPreview);
151+
const prevFilePathRef = useRef(selectedFile?.path);
152+
153+
// Reset fullscreen when preview is hidden or file changes
154+
if (
155+
prevShowPreviewRef.current !== showPreview ||
156+
prevFilePathRef.current !== selectedFile?.path
157+
) {
158+
prevShowPreviewRef.current = showPreview;
159+
prevFilePathRef.current = selectedFile?.path;
160+
if (isPreviewFullscreen) {
161+
setIsPreviewFullscreen(false);
162+
}
163+
}
157164

158165
const handleTogglePreviewFullscreen = useCallback(() => {
159166
setIsPreviewFullscreen((prev) => !prev);
160167
}, []);
161168

162-
useEffect(() => {
163-
if (!showPreview) {
164-
setIsPreviewFullscreen(false);
165-
}
166-
}, [showPreview]);
167-
168-
useEffect(() => {
169-
setIsPreviewFullscreen(false);
170-
}, [selectedFile?.path]);
171-
172169
useEffect(() => {
173170
if (!isPreviewFullscreen) return;
174171

frontend/src/components/editor/file-preview/PDFPreview.tsx

Lines changed: 15 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useMemo, useState, useEffect } from 'react';
1+
import { memo, useMemo, useEffect, useState, useRef } from 'react';
22
import { logger } from '@/utils/logger';
33
import { base64ToUint8Array } from '@/utils/base64';
44
import type { FileStructure } from '@/types/file-system.types';
@@ -17,52 +17,35 @@ export const PDFPreview = memo(function PDFPreview({
1717
isFullscreen = false,
1818
onToggleFullscreen,
1919
}: PDFPreviewProps) {
20-
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
21-
const [isLoading, setIsLoading] = useState(true);
22-
const [error, setError] = useState(false);
2320
const fileName = getDisplayFileName(file, 'document.pdf');
21+
const [iframeError, setIframeError] = useState(false);
2422

25-
const blobUrl = useMemo(() => {
23+
const pdfUrl = useMemo(() => {
2624
if (!file.content || !isValidBase64(file.content)) return null;
2725

2826
try {
2927
const bytes = base64ToUint8Array(file.content);
3028
const blob = new Blob([bytes as unknown as BlobPart], { type: 'application/pdf' });
3129
return URL.createObjectURL(blob);
32-
} catch (error) {
33-
logger.error('PDF preview load failed', 'PDFPreview', error);
30+
} catch (err) {
31+
logger.error('PDF preview load failed', 'PDFPreview', err);
3432
return null;
3533
}
3634
}, [file.content]);
3735

38-
useEffect(() => {
39-
if (blobUrl) {
40-
setPdfUrl(blobUrl);
41-
setError(false);
42-
} else {
43-
setError(true);
44-
}
45-
setIsLoading(false);
36+
const prevPdfUrlRef = useRef(pdfUrl);
37+
if (prevPdfUrlRef.current !== pdfUrl) {
38+
prevPdfUrlRef.current = pdfUrl;
39+
setIframeError(false);
40+
}
4641

42+
useEffect(() => {
4743
return () => {
48-
if (blobUrl) {
49-
URL.revokeObjectURL(blobUrl);
50-
}
44+
if (pdfUrl) URL.revokeObjectURL(pdfUrl);
5145
};
52-
}, [blobUrl]);
53-
54-
if (isLoading) {
55-
return (
56-
<PreviewEmptyState
57-
fileName={fileName}
58-
message="Loading PDF..."
59-
isFullscreen={isFullscreen}
60-
onToggleFullscreen={onToggleFullscreen}
61-
/>
62-
);
63-
}
46+
}, [pdfUrl]);
6447

65-
if (error || !pdfUrl) {
48+
if (!pdfUrl || iframeError) {
6649
return (
6750
<PreviewEmptyState
6851
fileName={fileName}
@@ -84,7 +67,7 @@ export const PDFPreview = memo(function PDFPreview({
8467
src={pdfUrl}
8568
className="h-full w-full border-0"
8669
title={fileName}
87-
onError={() => setError(true)}
70+
onError={() => setIframeError(true)}
8871
/>
8972
</PreviewContainer>
9073
);

frontend/src/components/editor/file-tree/SearchInput.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useEffect, useRef, type ChangeEvent, type KeyboardEvent } from 'react';
1+
import { useRef, type ChangeEvent, type KeyboardEvent } from 'react';
2+
import { useMountEffect } from '@/hooks/useMountEffect';
23
import { Search, X } from 'lucide-react';
34
import { Button } from '@/components/ui/primitives/Button';
45
import { cn } from '@/utils/cn';
@@ -37,7 +38,7 @@ export function SearchInput({
3738
inputRef.current?.focus();
3839
};
3940

40-
useEffect(() => {
41+
useMountEffect(() => {
4142
const handleKeyboardShortcut = (event: globalThis.KeyboardEvent) => {
4243
if ((event.ctrlKey || event.metaKey) && event.key === 'f') {
4344
const activeElement = document.activeElement;
@@ -55,7 +56,7 @@ export function SearchInput({
5556

5657
window.addEventListener('keydown', handleKeyboardShortcut);
5758
return () => window.removeEventListener('keydown', handleKeyboardShortcut);
58-
}, []);
59+
});
5960

6061
return (
6162
<div role="search" className={cn('relative flex items-center', className)}>

0 commit comments

Comments
 (0)