Skip to content

Perf: Zustand selector audit (useShallow + memoization) (#79)#105

Merged
thetechjon merged 5 commits into
devfrom
perf/zustand-selector-audit-79
Jun 6, 2026
Merged

Perf: Zustand selector audit (useShallow + memoization) (#79)#105
thetechjon merged 5 commits into
devfrom
perf/zustand-selector-audit-79

Conversation

@thetechjon
Copy link
Copy Markdown
Collaborator

Summary

Audit of Zustand selectors across src/components/** and src/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.is snapshot equality (always false because set() replaces
state) 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:

  • separate single-field selectors (1 line per field) when the count is small,
    which is fine for Zustand because action refs are stable, OR
  • a single 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, VaultSettingsConflictModal

Sidebar (5) — the hottest re-render path:

  • Sidebar, ContextMenu (useShallow x2), FolderTree (useShallow x2), FolderTreeToolbar, CalendarView

Editor + shell (5):

  • Pane, EditorHeader, EditorContent, app/page.tsx, useKeyboardShortcuts

Memoization (2):

  • EditorContent.activeNotesgetActiveNotes().filter(n => n.id !== note.id) was rebuilt every keystroke; now memoised on [notes, note.id].
  • PluginsSettingsPanel.recordListObject.values(records).sort(...) was rebuilt every render; now memoised on [records].

Counts

  • 22 destructured useStore() calls split into per-field selectors
  • 4 useShallow wraps added (ContextMenu x2, DeleteConfirmModal, FolderTree x2)
  • 2 derived-data useMemos added

Intentionally NOT changed

  • Single-field selectors like const { modal } = useUIStore() were already
    fine — 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 whose
    inputs are guaranteed-new on every render (note.content prop changes).
  • BacklinksView, WelcomePane, MergeBatchView, FrontmatterPanel — all
    already had proper useMemo wrappers on their derived data.

Test plan

  • npm run lint — clean
  • npx tsc --noEmit — zero errors
  • npm test -- --ci — 2374 pass, 17 skipped, 0 fail
  • Manual: typing in the editor doesn't trip a sidebar re-render storm
  • Manual: opening a note from the tree still works (FolderTree's selector subscription updates)
  • Manual: theme / settings changes still propagate (Settings modal selector still wired)

Behavior-preserving — same fields read in every place, just narrower subscriptions.

Closes #79

🤖 Generated with Claude Code

…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
@thetechjon thetechjon merged commit 79a191d into dev Jun 6, 2026
3 checks passed
@thetechjon thetechjon deleted the perf/zustand-selector-audit-79 branch June 6, 2026 14:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants