Skip to content

State Management

The Lens frontend uses no external state library. All state is managed through React’s built-in primitives: useState, useCallback, useMemo, and useRef. This document describes the data flow architecture, state ownership, and persistence strategies.

LensView (at src/pages/LensView.tsx) is the single source of truth for all data and UI state within the portfolio view. It delegates state management to 12+ custom hooks in src/pages/lens-view/, each owning a specific concern:

LensView
├── useLensDataLoader → nodes, views, syncAgents, lensName, permissions
├── useLensViewDraft → columns, density, formatting, pipeline, gantt config
├── useLensContextBootstrap → field metadata, priorities, user prefs, admin status
├── useLensDialogState → open/close booleans for all dialogs
├── useLensIncrementalSync → realtime updates, incremental diffing, animations
├── useLensRealtimeController → Forge Realtime subscription lifecycle
├── useLensSchedulingController → auto-schedule coordination
├── useLensSelection → row selection state
├── useLensInspectorState → inspector panel, section expand/collapse
├── useLensExport → export logic
├── useLensCreateController → inline issue creation
├── useSearch → search term and match state
├── useQuickFilters → quick filter chips
├── useColumnFilters → per-column filter state
├── useDragDrop → DnD state and handlers
├── useKeyboardShortcuts → keyboard bindings
├── useJobStatus → async job polling
├── useRetryQueue → failed operation retry
├── useNetworkStatus → online/offline detection
User navigates to /lens/:id
LensView mounts → useLensDataLoader fires
├─ Check prefetch cache (getPrefetchedView)
│ └─ Hit? Use cached data, skip API call
├─ api.getLensViewByNumericId(numericId)
│ Returns: { lensId, rows, totalCount, lensName,
│ views, syncAgents, userPermissionLevel, ownerAccountId }
├─ api.initLensContext(lensId)
│ Returns: { fields, priorities, hierarchy, preferences, isAdmin }
State populated:
nodes ← rows (HierarchyNode[])
views ← ViewConfig[] (apply default view)
syncAgents ← sync agent configs
fieldMetadata ← field definitions for editor type resolution
columns ← from default view config
density ← from default view config
formattingRules ← from default view config

utils/prefetch.ts provides hover-to-prefetch for lens cards:

// On mouseenter of a lens card:
prefetchLensView(lensId); // Starts after 150ms dwell
// On mouseleave:
cancelPrefetchLensView(lensId); // Cancels if dwell threshold not met
// On navigation:
const cached = getPrefetchedView(lensId); // Returns data or null

Configuration:

  • TTL: 30 seconds — cached data expires after this period
  • Dwell: 150ms — hover must persist before fetch starts
  • Concurrency: 2 simultaneous prefetches max
  • Cache limit: 5 entries (LRU eviction by lastAccessedAt)
  • Version tracking: Bumped on invalidation to prevent stale responses from landing

The backend returns nodes in SQL order (depth ASC, position ASC), which is breadth-first-like. The frontend converts this to depth-first tree-walk order for rendering.

src/components/Grid/HierarchyGrid.tsx
export function buildFlatRows(nodes: HierarchyNode[]): FlatRow[]

This function:

  1. Calls treeWalkOrder(nodes) to reorder nodes into depth-first traversal
  2. Resolves orphan promotion — nodes whose parent is missing are promoted to root
  3. Maps each HierarchyNode to a FlatRow with flattened issueData fields

Critical invariant: Any component rendering content aligned with grid rows (Gantt bars, dependency arrows, row highlights) MUST use the same depth-first tree-walk ordering. The Gantt’s extractGanttItems() uses the same treeWalkOrder() function internally.

interface FlatRow {
id: string;
node_type: string; // 'issue' | 'flex' | 'generator_group' | 'milestone'
jira_issue_key: string | null;
flex_name: string | null;
depth: number;
parent_id: string | null; // Effective parent (null for orphan-promoted nodes)
position: number;
hasChildren: boolean;
milestone_date: string | null;
// All issueData fields spread flat:
summary: string | null;
status_name: string | null;
assignee_display_name: string | null;
priority_name: string | null;
due_date: string | null;
start_date: string | null;
story_points: number | null;
// ... (all CachedIssue fields)
}

Manages the “draft” state of the current view configuration — what the user sees vs. what is saved. Tracks dirty state for save/revert.

Managed state:

StateTypePersisted In
viewIdstringForge SQL (views table)
columnsColumnConfig[]Forge SQL
densityDensityModeForge SQL
wrapRowsbooleanForge SQL
formattingRulesFormattingRule[]Forge SQL
assigneeDisplayModeAssigneeDisplayModeForge SQL
columnsFrozenbooleanForge SQL
ganttEnabledbooleanForge SQL
ganttWidthnumberForge SQL
ganttConfigGanttConfigForge SQL
criticalPathEnabledbooleanForge SQL
resourcePanelEnabledbooleanForge SQL
autoScheduleEnabledbooleanForge SQL
sortConfigSortConfigForge SQL
pipelinePipelineOperation[]Forge SQL
progressModeProgressModeForge SQL

Methods:

  • applyViewState(view) — loads a saved view config into the draft
  • handleSaveView() — persists the current draft to the backend
  • handleRevertView() — discards changes and reloads from saved state
  • isDirty — boolean indicating unsaved changes

Views are stored in Forge SQL (views table) with the full configuration serialized as JSON columns. When a user switches views, the frontend calls applyViewState() which replaces all draft state in a single batch.

Some UI preferences are stored in localStorage with a foundation- prefix:

Key PatternData
foundation-{lensId}-inspector-openInspector panel open state
foundation-{lensId}-section-{sectionId}Accordion section expanded state
foundation-sort-presetsSort/group presets
foundation-densityLast-used density mode

localStorage is used for ephemeral UI state that doesn’t warrant a server round-trip and shouldn’t be shared between users.

1. User double-clicks cell
2. AG Grid opens cell editor
(isEditingRef.current = true)
3. User commits edit (Enter, select, or click-outside)
4. onCellEdit callback fires
├─ Optimistic update: setNodes() with new value
│ └─ Grid immediately shows the new value
├─ api.updateField(lensId, issueKey, field, value)
│ │
│ ├─ Success: Backend updates Jira + cache
│ │ └─ Realtime event may trigger smartRefresh
│ │
│ └─ Failure: Revert optimistic update
│ └─ toast.editFailed(issueKey, field, error)
5. isEditingRef.current = false

Key detail: isEditingRef is a useRef, not useState. This prevents re-renders while AG Grid is in edit mode, which would destroy the active editor. See Inline Editing for the full explanation.

useLensIncrementalSync handles live updates from other users and background operations:

Forge Realtime event arrives (hierarchy_changed, field_updated, etc.)
handleRealtimeFieldUpdate(event)
├─ If currently editing → defer update
├─ smartRefresh() → api.getLensDiff(lensId, lastJobId)
│ Returns: { added, moved, removed, hidden, unhidden }
├─ Apply diff to nodes state:
│ ├─ New rows → newRowIds (green highlight animation)
│ ├─ Updated rows → updatedRowIds (blue highlight animation)
│ ├─ Changed fields → changedFields map (cell-level animation)
│ └─ Removed rows → removingRowIds (strikethrough + slide animation)
Animation timers clear highlight sets after 2-3 seconds

The pipeline is a composable chain of sort/group/filter operations stored in the view config:

type PipelineOperation =
| SortOperation // { type: 'sort', field, direction }
| GroupOperation // { type: 'group', field }
| FilterOperation // { type: 'filter', field, operator, value }
| CollapseOperation // { type: 'collapse', depth }

HierarchyGrid executes the pipeline via executePipeline(nodes, pipeline) from utils/pipelineEngine.ts. The pipeline runs client-side on the flat row data after buildFlatRows().

utils/toasts.ts wraps Forge bridge’s showFlag() for standardized notifications:

import { toast } from '../utils/toasts';
toast.success('Saved'); // Auto-dismiss
toast.error('Failed', 'Details here'); // Sticky
toast.warning('Rate limit approaching'); // Auto-dismiss
toast.editFailed('PROJ-123', 'summary', 'err'); // Formatted edit failure
toast.handleResult(apiResult, 'Success msg'); // Auto-detect success/error
toast.permissionDenied(); // Standard permission error
toast.networkError(); // Standard network error
// In HierarchyGrid:
const flatRows = useMemo(() => buildFlatRows(nodes), [nodes]);
// Pipeline transforms (sort/group/filter) applied next:
const processedRows = useMemo(
() => executePipeline(flatRows, pipeline),
[flatRows, pipeline]
);
// Collapsed rows filtered:
const visibleRows = useMemo(
() => filterCollapsedRows(processedRows, gridCollapsedIds),
[processedRows, gridCollapsedIds]
);
// visibleRows becomes AG Grid's rowData
// In HierarchyGrid:
const columnDefs = useMemo(
() => buildColumnDefs(columns, {
density,
wrapRows,
formattingRules,
assigneeDisplayMode,
fieldTypeMap,
fieldMetadata,
dateFormatMode,
// ... 15+ configuration options
}),
[columns, density, wrapRows, /* ... */]
);

buildColumnDefs() (in columns.tsx) maps each ColumnConfig to an AG Grid ColDef with:

  • Appropriate cell renderer based on field type
  • Cell editor based on field type (for inline editing)
  • Value getters that extract from the flat row data
  • Width, wrap, and formatting configuration
  • Column header menu integration

useKeyboardShortcuts registers global keyboard handlers:

ShortcutAction
Delete / BackspaceDelete selected nodes
Ctrl/Cmd+ASelect all rows
Ctrl/Cmd+KOpen command palette
Ctrl/Cmd+IToggle inspector panel
Ctrl/Cmd+FFocus search input
Shift+CSelect children (when selection exists)
Ctrl+Shift+1-5Switch density mode
Enter (on row)Open inline create below
ArrowLeft/RightExpand/collapse node

The hook uses a useRef for handlers to keep the event listener stable across re-renders.