Perf: Zustand selector audit (useShallow + memoization) (#79)#105
Merged
Conversation
…ing whole store
Modal components previously did `const { modal, closeModal } = useUIStore()`,
which subscribes the component to EVERY UI store change (preview toggle,
sidebar resize, search query keystrokes, etc.) — not just the modal slot.
Same anti-pattern for note/folder/tag store destructuring inside modals.
Per-field selectors (and useShallow for DeleteConfirmModal's 6+ action grab)
narrow each subscription to exactly the fields the component reads. Stable
action refs mean only real state-field changes re-render now.
Behavior preserving — same fields selected, just one selector per field.
Refs #79
Sidebar/FolderTree is the hottest re-render path — they read tens of fields across noteStore + folderStore + uiStore. Before this change, every keystroke in the editor (which mutates noteStore.notes) re-rendered the entire folder tree because the destructured `useNoteStore()` subscribes to the whole state. useShallow on the multi-field grabs in FolderTree + ContextMenu narrows each subscription to the actual set of fields read. Single-field grabs (Sidebar, CalendarView, FolderTreeToolbar) split into one selector per field — stable action refs mean no extra work. Behavior preserving — same fields selected. Refs #79
…tor + shell Same pattern as the modals/sidebar passes — destructured `useStore()` calls subscribe to every state change in the store. Split each multi-field grab into per-field selectors so a typed character (which mutates noteStore.notes) no longer re-renders the editor chrome, the keyboard-shortcut hook, or the page-level layout shell. Single-field grabs only (no useShallow needed here). Behavior preserving. Refs #79
Two derived computations were rebuilt on every render even though their inputs only change on actual data mutations: - EditorContent.activeNotes: `getActiveNotes().filter(n => n.id !== note.id)` ran on every keystroke (preview-mode re-render). Now memoised on the notes array identity + the current note id. - PluginsSettingsPanel.recordList: `Object.values(records).sort(...)` was ignoring React's render cycle. Memoised on the records map. Both are pure derived data — no behavior change, just fewer allocations. Refs #79
Splitting `const { sidebarCollapsed, sidebarWidth, rightSidebarCollapsed,
rightSidebarWidth } = useUIStore()` into four per-field selectors made
page.tsx re-render less during the post-mount window. That shifted the
killswitch useEffect (deps: `[hydrated]`) to fire AFTER the e2e harness
created its first note via Alt+N — which then trips `hasUnsyncedChanges`
and pops the ResetConfirmModal mid-test, breaking
e2e/attachment-drag.spec.ts.
Restore the original destructure here and leave an inline note. The
killswitch race itself is a separate bug — `decideResetAction` should
wait for `useNoteStore.persist.hasHydrated()` before reading notes — and
the perf win on this single component is negligible (the page shell
re-renders on UI store mutations regardless, since it owns the modal
roots).
Behavior preserving in the only failing direction; lint + typecheck + jest
+ the previously-failing e2e all green locally.
Refs #79
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Audit of Zustand selectors across
src/components/**andsrc/hooks/**per #79.The dominant anti-pattern was
const { a, b } = useXxxStore()(no selector) —which subscribes the component to EVERY change in the store, then relies on
React's
Object.issnapshot equality (always false becauseset()replacesstate) to bail out. In practice every keystroke in the editor was re-rendering
the entire sidebar tree, every modal, the editor chrome, the page shell, etc.
This PR splits each multi-field destructure into either:
which is fine for Zustand because action refs are stable, OR
useShallow(s => ({ ... }))grab when there are 5+ fields.Plus two derived-data computations that were rebuilt every render.
Files touched (30)
Modals (19) — every modal was destructuring
useUIStore()for{ modal, closeModal }:AIResultModal,BugReportModal,DeleteConfirmModal(useShallow),DiscardLocalChangesModal,ExportModal,FileHistoryModal,GitHubAuthModal,GitHubRepoModal,LocalFolderImportModal,PluginInstallConfirmModal,PublishGistModal,RevertToCommitModal,SearchModal,SettingsModal,ShortcutsModal,TaskEditModal,TemplatesModal,VaultEncryptionModal,VaultSettingsConflictModalSidebar (5) — the hottest re-render path:
Sidebar,ContextMenu(useShallow x2),FolderTree(useShallow x2),FolderTreeToolbar,CalendarViewEditor + shell (5):
Pane,EditorHeader,EditorContent,app/page.tsx,useKeyboardShortcutsMemoization (2):
EditorContent.activeNotes—getActiveNotes().filter(n => n.id !== note.id)was rebuilt every keystroke; now memoised on[notes, note.id].PluginsSettingsPanel.recordList—Object.values(records).sort(...)was rebuilt every render; now memoised on[records].Counts
useStore()calls split into per-field selectorsIntentionally NOT changed
const { modal } = useUIStore()were alreadyfine — there's no destructure pattern that's both single-field and unwrapped
(every one-field grab in the codebase already uses
s => s.foo).EditorFooter.wordCount/tagCount— cheap per-content derivations whoseinputs are guaranteed-new on every render (note.content prop changes).
BacklinksView,WelcomePane,MergeBatchView,FrontmatterPanel— allalready had proper
useMemowrappers on their derived data.Test plan
npm run lint— cleannpx tsc --noEmit— zero errorsnpm test -- --ci— 2374 pass, 17 skipped, 0 failBehavior-preserving — same fields read in every place, just narrower subscriptions.
Closes #79
🤖 Generated with Claude Code