# Phase 2: State Management Optimization ## Status: Partially shipped **Shipped under PR #75** — memoization pass on the wrapper functions, `React.memo` on the three heaviest children, and the consolidated reset helpers. Released as part of 0.5.0 (see [`CHANGELOG.md`](../../CHANGELOG.md)). **Superseded in part by PR #109** — the "Phase 2 hook extraction" pushed two full cross-hook pipelines into their own hooks (`useN1Fetch`, `useDiagramHighlights`), which subsumes some of the wrapper-memoization discussion below because those wrappers no longer exist in `App.tsx` at all. **Still deferred** — the three orchestrator hooks called out in the [App.tsx refactor-history table in `frontend/CLAUDE.md`](../../frontend/CLAUDE.md) (`useSettingsOrchestration`, `useSaveLoadSession`, `useStateReset`), which would absorb the remaining `wrappedSaveResults` / `wrappedRestoreSession` / `resetAllState` complexity. See the "Deferred" section in that file for the tradeoff notes. > The sections below are preserved as the original design record > for Phase 1 (App.tsx ≈ 650 lines). Line numbers and the > wrapper-function inventory reflect that snapshot and are out of > date — `App.tsx` is now ≈ 1400 lines after the 0.7.0 work > (PR #109 dropped it to ~1150; the interactive overflow viewer + > design-token migration + tier warning system pushed it back up). > Start from `frontend/CLAUDE.md` for the current shape. --- **Scope:** `frontend/src/App.tsx` + hooks in `frontend/src/hooks/` **Risk:** Low — incremental changes, no new dependencies, each step independently shippable **Estimated impact:** Eliminates unnecessary re-renders of the 3 heaviest components (VisualizationPanel: 1266 lines, ActionFeed: ~500 lines, OverloadPanel) --- ## Problem Statement Phase 1 successfully extracted 5 custom hooks from App.tsx (~2100 → ~650 lines). However, the **cross-hook wiring layer** (lines 101–175) introduces performance and maintainability issues: ### Issue 1: Zero memoization on wrapper functions 9 wrapper functions bridge hooks together. **None are wrapped with `useCallback`**, so every App.tsx render creates new function references, defeating `React.memo` on children. ``` Line 102 wrappedActionSelect — NO useCallback Line 105 wrappedActionFavorite — NO useCallback Line 108 wrappedManualActionAdded — NO useCallback Line 128 wrappedRunAnalysis — NO useCallback Line 131 wrappedDisplayPrioritized — NO useCallback Line 134 wrappedAssetClick — NO useCallback Line 137 wrappedSaveResults — NO useCallback (12 lines, 18+ params) Line 151 wrappedOpenReloadModal — NO useCallback Line 153 wrappedRestoreSession — NO useCallback (23 lines, 30+ params) ``` ### Issue 2: Massive parameter objects for session save/restore `wrappedSaveResults` passes 18+ fields to `SaveParams`. `wrappedRestoreSession` passes 30+ setters into `RestoreContext`. Both construct new object literals on every render. ### Issue 3: Duplicated reset logic `clearContingencyState` (line 112), `handleApplySettings` (line 183), and `handleLoadConfig` (line 252) each perform overlapping sequences of 15–20 state resets. The reset lists are manually synchronized — adding a new piece of state means updating 3 places. ### Issue 4: No `React.memo` on child components - `VisualizationPanel` receives 35+ props including 8 inline callback expressions (lines 587–620) - `ActionFeed` receives 50+ props including 7 settings values passed individually - `OverloadPanel` receives 13 props with inline arrow functions Without `React.memo`, every App.tsx state change re-renders all children even when their props haven't changed. With `React.memo` but without memoized callbacks, the memo check fails because callback references change every render. ### Issue 5: Settings values drilled individually ActionFeed receives 8 individual settings props (`minLineReconnections`, `minCloseCoupling`, `minOpenCoupling`, etc.) that are only used for display — they could be passed as a single memoized object. --- ## Proposed Changes ### Step 1: Memoize all wrapper functions with `useCallback` **Files:** `App.tsx` **Risk:** Very low — purely additive, no behavioral change Wrap all 9 cross-hook bridging functions with `useCallback` and explicit dependency arrays: ```tsx // Before (line 102): const wrappedActionSelect = (actionId: string | null) => diagrams.handleActionSelect(actionId, result, selectedBranch, voltageLevels.length, setResult, setError); // After: const wrappedActionSelect = useCallback( (actionId: string | null) => diagrams.handleActionSelect(actionId, result, selectedBranch, voltageLevels.length, setResult, setError), [diagrams, result, selectedBranch, voltageLevels.length, setResult, setError] ); ``` For `wrappedSaveResults` and `wrappedRestoreSession`, memoize the parameter objects: ```tsx const saveParams = useMemo(() => ({ networkPath, actionPath, layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, minPst, minLoadShedding, minRenewableCurtailmentActions, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, selectedBranch, selectedOverloads, monitorDeselected, nOverloads: nDiagram?.lines_overloaded ?? [], n1Overloads: n1Diagram?.lines_overloaded ?? [], result, selectedActionIds, rejectedActionIds, manuallyAddedIds, suggestedByRecommenderIds, setError, setInfoMessage: analysis.setInfoMessage, }), [ networkPath, actionPath, layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, minPst, minLoadShedding, minRenewableCurtailmentActions, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, selectedBranch, selectedOverloads, monitorDeselected, nDiagram?.lines_overloaded, n1Diagram?.lines_overloaded, result, selectedActionIds, rejectedActionIds, manuallyAddedIds, suggestedByRecommenderIds, setError, analysis.setInfoMessage, ]); const wrappedSaveResults = useCallback( () => session.handleSaveResults(saveParams), [session, saveParams] ); ``` **Validation:** `npx tsc --noEmit` + `npm run test` + manual verification that save/restore still works. --- ### Step 2: Extract a centralized reset function **Files:** `App.tsx` (new helper), or a new `hooks/useResetState.ts` **Risk:** Low — consolidates existing behavior Currently, the same 15+ state resets are copy-pasted in 3 places: | Reset target | `clearContingencyState` | `handleApplySettings` | `handleLoadConfig` | |---|---|---|---| | `setResult(null)` | ✓ | ✓ | ✓ | | `setPendingAnalysisResult(null)` | ✓ | ✓ | ✓ | | `setSelectedOverloads(new Set())` | ✓ | ✓ | ✓ | | `setMonitorDeselected(false)` | ✓ | ✓ | ✓ | | `clearActionState()` | ✓ | ✓ | ✓ | | `setSelectedActionId(null)` | ✓ | ✓ | ✓ | | `setActionDiagram(null)` | ✓ | ✓ | ✓ | | `setActiveTab(...)` | ✓ (`'n'`) | ✓ (`'n'`) | ✓ (`'n'`) | | `setVlOverlay(null)` | ✓ | ✓ | ✓ | | `setError('')` | ✓ | ✓ | ✓ | | `setInfoMessage('')` | ✓ | ✓ | ✓ | | `setInspectQuery('')` | ✓ | ✓ | ✓ | | `lastZoomState reset` | ✓ | ✓ | ✓ | | `setNDiagram(null)` | | ✓ | ✓ | | `setN1Diagram(null)` | | ✓ | ✓ | | `setOriginalViewBox(null)` | | ✓ | ✓ | | `setSelectedBranch('')` | | ✓ | ✓ | | `setActionViewMode('network')` | | ✓ | ✓ | Proposal: extract two levels of reset: ```tsx // Resets analysis/action state but preserves the loaded network and diagrams const resetContingencyState = useCallback(() => { analysis.setResult(null); analysis.setPendingAnalysisResult(null); analysis.setSelectedOverloads(new Set()); analysis.setMonitorDeselected(false); actionsHook.clearActionState(); diagrams.setSelectedActionId(null); diagrams.setActionDiagram(null); diagrams.setActiveTab('n'); diagrams.setVlOverlay(null); setError(''); analysis.setInfoMessage(''); diagrams.setInspectQuery(''); diagrams.lastZoomState.current = { query: '', branch: '' }; }, [setError, actionsHook, analysis, diagrams]); // Full reset: contingency state + network/diagram state const resetAllState = useCallback(() => { resetContingencyState(); diagrams.setNDiagram(null); diagrams.setN1Diagram(null); diagrams.setOriginalViewBox(null); diagrams.setActionViewMode('network'); diagrams.setN1Loading(false); diagrams.setActionDiagramLoading(false); diagrams.committedBranchRef.current = ''; diagrams.actionSyncSourceRef.current = null; setSelectedBranch(''); setShowMonitoringWarning(false); }, [resetContingencyState, diagrams, setSelectedBranch, setShowMonitoringWarning]); ``` Then `handleApplySettings` and `handleLoadConfig` become: ```tsx const handleApplySettings = useCallback(async () => { interactionLogger.record('settings_applied'); try { resetAllState(); // ... config loading logic (unchanged) } catch (err) { ... } }, [resetAllState, ...]); ``` **Validation:** Behavior-identical — diff the before/after reset sequences to confirm. --- ### Step 3: Add `React.memo` to heavy child components **Files:** `VisualizationPanel.tsx`, `ActionFeed.tsx`, `OverloadPanel.tsx` **Risk:** Low — `React.memo` is a no-op when props actually change; it only skips renders when props are referentially equal **Prerequisite:** Step 1 (memoized callbacks) must land first, otherwise memo checks will always fail ```tsx // VisualizationPanel.tsx — wrap the export export default React.memo(VisualizationPanel); // ActionFeed.tsx export default React.memo(ActionFeed); // OverloadPanel.tsx export default React.memo(OverloadPanel); ``` **Why not earlier?** Without Step 1, `React.memo` adds overhead (shallow comparison) without benefit (callbacks change every render). With Step 1 done, memo guards become effective. --- ### Step 4: Group settings props into a memoized object for ActionFeed **Files:** `App.tsx`, `ActionFeed.tsx` (props interface) **Risk:** Low — type-safe refactor, no behavioral change Currently ActionFeed receives 8 individual settings values: ```tsx // App.tsx lines 568-578 minLineReconnections={minLineReconnections} minCloseCoupling={minCloseCoupling} minOpenCoupling={minOpenCoupling} minLineDisconnections={minLineDisconnections} minPst={minPst} minLoadShedding={minLoadShedding} minRenewableCurtailmentActions={minRenewableCurtailmentActions} nPrioritizedActions={nPrioritizedActions} ignoreReconnections={ignoreReconnections} ``` Proposal: group into a single memoized object: ```tsx // types.ts export interface RecommenderDisplayConfig { minLineReconnections: number; minCloseCoupling: number; minOpenCoupling: number; minLineDisconnections: number; minPst: number; minLoadShedding: number; minRenewableCurtailmentActions: number; nPrioritizedActions: number; ignoreReconnections: boolean; } // App.tsx const recommenderConfig = useMemo(() => ({ minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, minPst, minLoadShedding, minRenewableCurtailmentActions, nPrioritizedActions, ignoreReconnections, }), [ minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, minPst, minLoadShedding, minRenewableCurtailmentActions, nPrioritizedActions, ignoreReconnections, ]); // Pass as single prop ``` **Benefit:** Reduces ActionFeed props from ~33 to ~25. The memoized object only changes when a setting actually changes, not on every render. --- ### Step 5: Eliminate inline callbacks in JSX **Files:** `App.tsx` **Risk:** Very low Several inline arrow functions are created in JSX on every render: ```tsx // Line 515 — contingency input onChange onChange={e => { interactionLogger.record('contingency_selected', { element: e.target.value }); setSelectedBranch(e.target.value); }} // Line 535 — dismiss monitoring warning onDismissWarning={() => setShowMonitoringWarning(false)} // Line 536 — open settings to configurations tab onOpenSettings={() => { setIsSettingsOpen(true); setSettingsTab('configurations'); }} // Line 587 — tab change onTabChange={(tab: TabId) => { interactionLogger.record('diagram_tab_changed', { tab }); setActiveTab(tab); }} // Line 601 — voltage range change onVoltageRangeChange={(range) => { interactionLogger.record('voltage_range_changed', ...); setVoltageRange(range); }} // Line 605 — inspect query change onInspectQueryChange={(q) => { interactionLogger.record('inspect_query_changed', ...); setInspectQuery(q); }} // Line 616 — VL open onVlOpen={(vlName) => handleVlDoubleClick(activeTab === 'action' ? selectedActionId || '' : '', vlName)} ``` Extract each into a named `useCallback`: ```tsx const handleTabChange = useCallback((tab: TabId) => { interactionLogger.record('diagram_tab_changed', { tab }); diagrams.setActiveTab(tab); }, [diagrams]); const handleVoltageRangeChange = useCallback((range: [number, number]) => { interactionLogger.record('voltage_range_changed', { min: range[0], max: range[1] }); diagrams.setVoltageRange(range); }, [diagrams]); const handleInspectQueryChange = useCallback((q: string) => { interactionLogger.record('inspect_query_changed', { query: q }); diagrams.setInspectQuery(q); }, [diagrams]); const handleVlOpen = useCallback((vlName: string) => { diagrams.handleVlDoubleClick( activeTab === 'action' ? selectedActionId || '' : '', vlName ); }, [diagrams, activeTab, selectedActionId]); const handleDismissWarning = useCallback(() => { setShowMonitoringWarning(false); }, [setShowMonitoringWarning]); ``` --- ## What This Proposal Does NOT Do (and why) ### No Context API / Zustand / Jotai migration The original proposal suggested a `useGridState` selector hook. This doesn't address the actual problems: - The hooks already own isolated state slices — there's no shared state store to "select" from. - `getActionById(id)` is just `result?.actions[id]` — a trivial lookup that doesn't need a hook. - `isActionSelected(id)` is just `manuallyAddedIds.has(id)` — already a O(1) Set lookup. - The real issue is **callback identity stability**, not data access patterns. A Context/store migration would require rewriting all 5 hooks, touching every component, and changing the data flow model — all for a benefit that memoization already provides. If the app grows significantly (10+ components needing the same state), Context becomes worthwhile; today it's premature. ### No component splitting beyond Phase 1 The current component boundaries (VisualizationPanel, ActionFeed, OverloadPanel, Header) are natural domain boundaries. Splitting them further would move complexity from props to component coordination without reducing it. ### No `useReducer` migration The 50+ `useState` calls in `useSettings` could theoretically be a reducer, but each field is independently set via forms. A reducer would add dispatch boilerplate without simplifying the update logic. --- ## Implementation Order | Step | Change | Files touched | Can ship independently | |------|--------|---------------|----------------------| | 1 | Memoize wrapper functions | `App.tsx` | Yes | | 2 | Extract centralized reset | `App.tsx` | Yes | | 3 | Add React.memo to children | 3 component files | Yes (after Step 1) | | 4 | Group settings props | `App.tsx`, `ActionFeed.tsx`, `types.ts` | Yes | | 5 | Extract inline callbacks | `App.tsx` | Yes | Steps 1 and 2 are independent and can be done in parallel. Step 3 depends on Step 1. Steps 4 and 5 are independent of each other but pair well with Step 3. --- ## Validation Plan 1. **Type safety:** `npx tsc --noEmit` after each step 2. **Lint:** `npm run lint` after each step 3. **Unit tests:** `cd frontend && npm run test` — existing tests for Header, SettingsModal, ReloadSessionModal, ConfirmationDialog, sessionUtils, interactionLogger 4. **Manual testing:** Full workflow — load network → select contingency → run analysis → star/reject actions → save/reload session 5. **Performance verification (optional):** React DevTools Profiler before/after Step 3 to measure re-render reduction on VisualizationPanel during action selection