Fix codebase audit regressions while preserving UI/UX behavior and adding review-driven hardening.
18 KiB
React Anti-Patterns Audit
Summary
Overall React health is moderate-to-good: the codebase mostly follows the repo's architectural rules (Zustand stores cover real shared state, Bitsocial hooks cover data fetching, Router is used for navigation). The anti-pattern concentration is in three places — a pervasive "derived-state via effect" style (scroll, title, keydown, resize), the two large Post components (post-desktop/post-mobile duplicate effect-heavy logic), and a handful of hook-level issues in use-directories, use-publish-reply, and the ModQueue subtree. There is almost no shared-state-in-useState anti-pattern left; the remaining useState calls are legitimately local UI state.
Findings
Critical
- src/hooks/use-directories.ts:331-462 —
useDirectoriesanduseDirectoriesStateare effectively the same hook duplicated wholesale (sameuseState+useEffect+ module-cache + localStorage + GitHub fetch pattern, ~60 lines each). Both fall under the "nouseEffectfor data fetching; no copy-paste logic" rules, and both power virtually every view viauseTheme,BoardLayout,Board,Catalog,Post,ModQueue,BoardsBar, etc. Fix: Collapse into a single Zustand directories store whoseensureHydrated()action is called once at app start (or from a thin shared wrapper hook). Remove the duplicateduseState/useEffectbodies; keepuseDirectories/useDirectoriesMetadataas pure selectors. This would also remove the awkwardreturn cacheCommunities || state.communities || ...ternary on line 397. - src/views/mod-queue/mod-queue.tsx:672-772 —
ModQueueCountItemis a "render-null-just-to-fire-an-effect-into-the-parent" pattern. Each feed item mounts, runsuseEffect(() => onStatusChange(cid, { awaiting, urgent }), [...])(line 682), and the parentModQueueButtonContentaggregates astatusMapuseState<Map>viahandleStatusChange. Also has a stale-cleanup effect on line 710. This is textbook derived state pushed up via effects, and causes an O(feed) cascade of renders whenever anything in the feed changes. Fix: ComputenormalCount/urgentCountduring render inModQueueButtonContentwith a plain.reduceoverfeed(the awaiting/urgent predicates are pure functions ofcomment,currentTime,alertThresholdSeconds). DeleteModQueueCountItem,statusMap,handleStatusChange, and the cleanup effect. - src/components/reply-modal/reply-modal.tsx:104-288 — 8
useEffects in one component, several of which are orchestrating state that could be derived or moved into the existinguseReplyModalStore/usePublishReplyhooks. Notable: line 156 syncsparentCidRef.current.style.widthfrom DOM measurement (fine), but line 163 mixes imperative textarea focus with an escape-key listener, line 186 is a dead one-shotuseEffect(..., [])setting selection range on an empty textarea, line 196 imperatively mutatestextRef.current.value(bypassing React) and callssetTimeout, line 226 guards an infinite loop vialastProcessedQuoteInsertRequestIdRef(see the comment on line 234 — the author acknowledged the effect fights itself). Fix: Split modal state into auseReplyModalControllerhook; model quote-insertion as an imperative handler triggered by store subscription (useEffecton arequestIdis the smell), not an effect driven by changing store values.
High
- src/components/post-desktop/post-desktop.tsx:930-1102 vs src/components/post-mobile/post-mobile.tsx:651-779 — post-desktop and post-mobile share three near-identical effect blocks: (1)
setResetFunctionwhen in post-page/pending view (desktop 930, mobile 651), (2)repliesResetRequestIdreset guard using a ref (desktop 937, mobile 658), (3) Virtuoso scroll-state save listener (desktop 1089, mobile 767). Both files are ~1000+ LOC and most of the reply-rendering logic above these effects is also duplicated (filterRepliesForDisplay,directRepliesByParentCid,useQuotedByMap,useReplyHeightEstimates,useProgressiveRender, etc.). Fix: Extract ausePostReplies({ isMobile })hook that owns the shared effects + memos, plus auseVirtuosoStateSave(key, enabled)hook for (3). The two components can keep only layout differences. - src/hooks/use-publish-reply.ts:135-208 vs src/hooks/use-publish-post.ts:86-98 — Both hooks use
useStatefor publish-error/state-string that the child component (PostForm,ReplyModal) only reads back, plus an effect that resets those states when the publish inputs change. This is derived state synced by effect; the reset values are allnull/false, and "when any publish input changes, error goes away" is a render-time computation using the previous error plus the latest input key. The two hooks also share theabandonPublishRef/publishOptionsWithAbandon/createBaseOptions/setPublishXxxOptionspattern almost verbatim (~80 LOC of duplication). Fix: Extract ausePublishLifecycle(store, { kind: 'post' | 'reply' })helper; move thepublishErrorreset into the publish action itself (on success/failure) instead of a dependency-array-driven effect. Consolidate the duplicate option-merging boilerplate. - src/components/boards-bar/boards-bar.tsx:31-62, 367-385 —
SearchBar(desktop) has three separate effects for (a) focus on mount, (b) click-outside close, (c) escape-key close;BoardsBarMobilethen repeats asetShowSearchBarpattern plus a scroll-listeneruseState(visible)+ debounced-scroll-effect for nav hide/show. The click-outside + escape-to-close + focus-on-mount triplet is repeated acrossSettingsModal,CatalogFilters,ChallengeModal,ReplyModal,CreateBoardModal. Fix: ExtractuseEscapeToClose(onClose),useClickOutsideToClose(ref, onClose),useAutoFocus(ref)intosrc/hooks/.useScrollDirection()would replace the hand-rolled scroll listener +visiblestate. - src/views/board/board.tsx:313-413 — Seven
useEffects in close proximity, most of which are route-sync effects guarded byisVisible. Several of them only derive pagination / infinite-scroll redirects fromlocation.pathname+effectiveInfiniteScroll+totalPages(lines 378, 386, 396) — candidates for computing the navigation during the click handler or render rather than after mount. The "has been visible" scroll-to-top effect (line 549) uses a ref flag to simulate a once-per-mount guard; akeyon the subtree oruseNavigationType()at render would be cleaner. Document-title effect on line 579 duplicates the same pattern as in catalog.tsx:779 and post.tsx:333. - src/views/catalog/catalog.tsx:320-795 — Six
useEffects, including a canonical-redirect effect (line 320) that duplicates the board.tsx:378 effect, asetCurrentCommunityAddressstore-sync effect (line 339), areset()triggered byfilteredComments.lengthvia a ref latch (line 513) — also duplicated at board.tsx:402 — asetResetFunctioneffect (line 522), and a doc-title effect (line 779). Fix: ExtractuseCanonicalMultiboardRedirect(location),useFeedResetFunction(reset, isVisible), anduseReplayFeedReset(filteredComments, reset)hooks shared between board and catalog. - Document title + scroll-to-top duplication across views (
src/views/blotter/blotter.tsx:15,src/views/faq/faq.tsx:7,src/views/pass/pass.tsx:32-38,src/views/rules/rules.tsx:181,src/views/pending-post/pending-post.tsx:19,src/views/home/home.tsx:193,src/views/archive/archive.tsx:208,src/views/board/board.tsx:579,src/views/catalog/catalog.tsx:779,src/views/post/post.tsx:333) — 10 views each implement their ownuseEffect(() => { document.title = ...; window.scrollTo(0, 0); }, [deps]). Fix: AdduseDocumentTitle(title)anduseScrollToTopOnMount()(or combineduseViewInit({ title })) insrc/hooks/and replace. - src/components/catalog-search/catalog-search.tsx:19-26 — URL query param
?q=is the source of truth, but the effect on line 19 pushesqueryParaminto theuseCatalogFiltersStoreon every change. The component also holds a separatesearchStateuseStateand computesinputValueandopenSearchby mixing URL, local state, and store. This is the "syncing derived state with an effect" + "boolean flag soup" smells combined. Fix: Make the store a selector of the URL (computesearchFilterfromlocation.searchduring render, or subscribe via a singleuseSearchFilterFromUrl()selector); remove the effect. ModelsearchStateas a discriminated{ mode: 'closed' } | { mode: 'open'; value: string }.
Medium
- src/hooks/use-fetch-gif-first-frame.ts:85-135 —
useState+useEffect+ module-levelSet<string>for failed URLs + localForage cache. Classic "data fetching in an effect" that the repo rule says should live in a Bitsocial hook or a Zustand store. Also uses status strings ('idle'|'loading'|'ready'|'failed') which is fine, but thesetGifFirstFrame((prev) => ...)identity checks (line 90, 95, 99, 126) are a code smell saying the effect is firing too often. Fix: Either (a) promote to a dedicated store indexed by URL with a thunk, or (b) turn into auseSyncExternalStore-backed cache so React subscribes rather than polls. - src/hooks/use-comment-media-info.ts:19-66 — Fetches thumbnail dimensions by constructing
new Image()insideuseEffect, stores the result in component-localuseState, then re-derivesCommentMediaInfowith the stored dimensions in auseMemo. This is "data fetch in effect" and the resultingthumbnailDimensionsis local per-consumer — two components rendering the samelinkwill both fetch and double-measure. Fix: Move the dimension cache to a module-levelMap<string, { w, h }>or Zustand store keyed bylink, driven by an async action invoked during render (suspense-style) or by a Bitsocial hook. - src/hooks/use-window-width.ts:3-15 + src/hooks/use-is-mobile.ts —
useState+ resize-listeneruseEffect. Fine in isolation, but every consumer subscribes independently (mobile vs desktop posts both calluseWindowWidth, as do reply-height estimates, catalog, boards-bar). On resize, every subscriber rerenders. Fix: Back withuseSyncExternalStoreso React can bail out when the value hasn't changed, or keep the subscription in a tiny Zustand store so the read surface is selector-based. - src/views/account-data-editor/account-data-editor.tsx:52-78 —
useStateholds a singleEditorStateobject withphase,AceEditor,aceOnBeforeLoad, andtext. The effect on line 59 runs only whenphase === 'loading'to importreact-ace, thensetEditorStatereplaces everything. This is a fine state machine, but handler functions mutate onlytext(line 141, 147) while phase is mutated viahandleContinue/handleGoBack. Theaccountdependency on line 78 will re-trigger the effect when account changes mid-load, which will double-import Ace. Fix: Split intophase+editor+textuseStates oruseReducerwith explicit actions; guard the dynamic import with a ref so it only runs once perphase === 'loading'transition. - src/components/settings-modal/settings-modal.tsx:45-65 —
expandedSections(aSet<string>) is localuseStatethat shadows the URL hash, and an effect on line 60 re-syncs the set fromhashwith an explicit// eslint-disable-next-line react-hooks/exhaustive-deps. The disable comment is a red flag: the author silenced the lint rule because the effect is "wrong" by the rules but the fix is deeper. Fix: DeriveexpandedSectionsfromhashplus a separate "user-added" set; or keep a single Zustand store for expanded sections so the hash is just the deep-linkable entry point. - src/views/post/post.tsx:293-297, 315-331, 381-429 — Seven effects on the
PostPagecomponent:/not-foundredirect (line 293), "scroll to top unless thread-top intent" split across two effects (lines 315, 321) withconsumedThreadTopScrollRefcoordinating them, a cleanup-onlyresetThreadLiveUpdateseffect (line 381), a thread-switch reset effect (line 387), and a refresh-by-cid orchestration effect (line 397-429). The split between the two scroll effects is explicitly documented in-file as subtle (comment at line 310) — a sign this is better modeled as oneuseLayoutEffecton[location.key]that reads the intent synchronously. Fix: Combine the two scroll effects into one keyed onlocation.key; move the refresh-by-cid orchestration intouseThreadLiveUpdatesStoreas an action invoked from a subscription in the store rather than an effect in the view. - src/components/reply-quote-preview/reply-quote-preview.tsx:133-148, 304-311 — Desktop and mobile variants each run their own
window.addEventListener('resize', handleResize)effect; same pattern (and same comment style) insrc/components/markdown/external-number-quote-link.tsx:83, andsrc/components/markdown/markdown.tsx:70. Fix: OneuseWindowResize(callback)hook. - src/components/edit-menu/edit-menu.tsx:45-110, 165-222 — Five
useStateslots plusdefaultPublishEditOptionsuseMemopluspublishCommentEditOptionsuseStateinitialized from the memo. WhendefaultPublishEditOptionschanges (lots of deps on line 92), the localuseStateis not reset — so opening the menu after the comment state changes can show stale options. Combined withbanDuration(line 165) also initialized fromdefaultPublishEditOptions.commentModeration?.author?.banExpiresAtonly once, this is a "state initialized from props and never re-synced" bug-magnet. Fix: Use akey={cid}wrapper or derive during render; for the editable slice of state that must be local, reset it in the "open" handler rather than by effect.
Low
- src/components/markdown/external-number-quote-link.tsx:53-56 — Four correlated
useStates (isResolving,isPreviewOpen,previewState,previewPosition). A discriminatedpreviewStateunion ({ kind: 'idle' } | { kind: 'resolving' } | { kind: 'open'; position } | { kind: 'error' }) would eliminate impossible combinations and thesetIsResolving(false)bookkeeping. - src/components/catalog-filters/catalog-filters.tsx:113-116, 240-252 — Two more escape-key-close effects; covered by the suggested
useEscapeToCloseextraction. - src/components/challenge-modal/challenge-modal.tsx:129-157, 232-305 — Four effects; line 129 sends a theme message post-load (fine), line 135 is a window-message handler (fine), line 232 and 297 are focus-on-mount and escape-to-close — again covered by the shared hooks extraction.
- src/index.tsx:52-58 —
window.history.back()inside the Capacitor back-button listener. Since the HashRouter is available at that point, this could benavigate(-1)via a small bridge, but the Capacitor listener is fired outside React tree, so callingwindow.history.back()directly is defensible. Fix: none required; keep the comment explicit and ignore from the "no manual history" rule since it's a native bridge, not in-app nav. - src/views/pass/pass.tsx:32-38 — Two adjacent
useEffect(() => { ... }, [])blocks; trivially combinable into one. Covered byuseDocumentTitle/useScrollToTopOnMount. - src/views/pending-post/pending-post.tsx:19, 28, 34 — Three effects, two of which are navigation side-effects. The
post-driven redirect on line 34 fires on everypostidentity change; should depend onpost?.cid+postCommunityAddressnot onpostas a whole. - src/components/boards-bar/boards-bar.tsx:140-142 —
useBoardsBarVisibilityStore.getState().initialize()called from an empty-depsuseEffect. If the store's initialize is idempotent this is fine, but it's an implicit "mount once, anywhere" that would be cleaner as a top-level store side-effect on creation. - src/components/post-form/post-form.tsx:391-476 — Five effects for display-name hydration, publish success navigation, reply success close, and upload callbacks — each fires once per success and sets a
hasInitializedDisplayNameref to guard re-runs. The ref-gating pattern (also in reply-modal.tsx:282 and use-publish-reply.ts:147) is a known smell that says "this should be an event, not an effect".
Top 5 Actions
-
Consolidate
useDirectories/useDirectoriesStateinto a Zustand store. Removes ~100 LOC of duplicated fetch-in-effect, simplifies the "stable reference for memoization" contract that currently leaks into every consumer, and fixes the rule-violating data-fetch-in-effect at the root of the dependency tree (src/hooks/use-directories.ts:331-462). -
Delete the
ModQueueCountItem/statusMapeffect cascade. ComputenormalCountandurgentCountpurely during render inModQueueButtonContent(src/views/mod-queue/mod-queue.tsx:672-772). This removes a per-feed-item render-null-to-fire-effect pattern that's both a textbook anti-pattern and a real performance hazard on boards with large queues. -
Create shared view-init hooks:
useDocumentTitle,useScrollToTopOnMount,useEscapeToClose,useClickOutsideToClose,useAutoFocus,useWindowResize,useScrollDirection. Replace the ~20 scattereduseEffects across views and modals (blotter,faq,pass,rules,home,archive,board,catalog,post,pending-post, plusboards-bar,settings-modal,catalog-filters,challenge-modal,reply-modal,reply-quote-preview,markdown,external-number-quote-link). High leverage, low risk — most replacements are mechanical. -
Refactor post-desktop / post-mobile shared logic into
usePostRepliesanduseVirtuosoStateSavehooks. Both files are ~1000+ LOC, they duplicate reply filtering, height estimation, progressive rendering, fresh-replies registration, reset-function wiring, and Virtuoso scroll-state save listeners. Extracting the shared tail end (fromfreshRepliesForRenderonward) into a hook cuts both files roughly in half and removes the parallel-edit risk. -
Split route-sync effects in
board.tsxandcatalog.tsxinto shared hooks. Specifically:useCanonicalMultiboardRedirect,useFeedResetFunction(reset, isVisible),useReplayFeedResetOnAccountComments(filteredComments, reset),useScrollToTopOnFirstVisible(isVisible, navigationType). Both views currently each carry 6-9 near-duplicate effects; consolidating them reduces effect-count at the view layer and makes the remaining view code closer to pure render.