Files
lasuite-drive/docs/architecture/explorer-selection-store.md
Nathan Panchout 3a730b87cf 📝(frontend) document explorer selection store architecture
Add an architecture doc covering the rationale behind the selection
store refactor: why React Context did not scale to 200+ rows during a
marquee drag, why useSyncExternalStore with per-id listeners enables
automatic bailout, and how global vs embedded scopes are wired.

Lives under docs/architecture/ for long-term reference.
2026-04-20 17:09:53 +02:00

18 KiB
Raw Permalink Blame History

Explorer selection — external store pattern

Context and symptom

In Drive, the user can select multiple files or folders in the explorer either by clicking them one by one, or by drawing a rubber-band (marquee) rectangle with the mouse — every item the rectangle covers is added to the selection.

On folders with a handful of items it works fine. But as soon as a folder holds ~200+ items on the same page, the marquee drag becomes janky: small freezes / FPS drops happen every time the mouse enters or leaves an item. The experience is broken on large folders — precisely the folders where multi selection matters the most.

Isolating the culprit

A simple experiment: replacing every cell body with a plain <div>hello</div> makes the lag disappear completely. Conclusion: the cost is not in updating the selection state itself, but in the render cost of the heavy cells (icons, tooltips, dnd-kit <Draggable> / <Droppable>, etc.) which were all re-rendered on every mouse tick.

Diagnosis — where did the render storm come from?

Before this refactor, the selection state lived in a useState<Item[]> on the GlobalExplorerContext provider. Any change called setSelectedItems, which triggered the following chain on every mousemove that touched an item:

  1. AppExplorerInner.onSelectionMove — the viselect callback fires on every mousemove and calls setSelectedItems(prev => [...]).
  2. GlobalExplorerContext — new state → new provider value → every consumer of useGlobalExplorer() re-renders (there are many across the tree).
  3. EmbeddedExplorerGrid — recomputes a local selectedItemsMap via useMemo that depends on the new array reference → new map.
  4. contextValue of the griduseMemo(..., [props, ...]) where props is a brand new object on every parent render, so this useMemo was effectively useless — it produced a new value every time.
  5. <tbody> — the 200+ rows were mapped inline without React.memo, so a parent re-render meant 200 <tr> re-renders.
  6. Cells (NameCell, ActionsCell, MobileCell) — consumed selectedItemsMap via useEmbeddedExplorerGridContext(). No memoization. All 200+ cells re-rendered even when their own isSelected had not changed.
  7. Heavy subtrees inside cells<Draggable> (dnd-kit), <Tooltip>, <ItemIcon>, <Icon>, <LoadingRing>, <Droppable> — all re-rendered on every tick for 200 rows.

In other words: a mousemove that only covered 3 new items still forced 200 full re-renders of heavy cells, not 3.

An existing comment in EmbeddedExplorerGrid.tsx already admitted the problem:

The context is only here to avoid the rerendering of react table cells when passing props to cells, with a context we avoid that by passing props via context, but it's quite overkill, unfortunately we did not find a better solution.

Routing through a React Context does not solve the problem — every consumer of a Context re-renders as soon as its value changes.

Chosen strategy

Goal: during a marquee drag, only the cells whose isSelected actually flipped should re-render. Everything else (parent EmbeddedExplorerGrid, the 197 unaffected rows, components higher up in the tree) must stay inert.

Approach: replace the React selectedItems: Item[] state with a small external store, compatible with React 18's useSyncExternalStore, that exposes per-id selector hooks. React bails out automatically when the snapshot returned by getSnapshot is equal (by Object.is) to the previous value → a component subscribed via useIsItemSelected(id) only re-renders when its boolean actually flips from false → true or true → false.

No new dependency: no Zustand or similar — a tiny store class plus useSyncExternalStore is enough (~150 lines).

Why useSyncExternalStore and not something else?

This is the core of the refactor: it is what makes the per-id bailout possible. Alternatives we considered:

Alternative 1 — useState + React Context

That was exactly what we had before. Structural issue: a React Context notifies all of its consumers whenever its value changes, with no way to filter by consumer. You cannot tell a row "only re-render when YOUR id flips", because React has no visibility into what each consumer actually looks at. Cannot scale to 200+ rows without tree-wide re-renders.

Alternative 2 — useState + prop-drilling with React.memo

Pass isSelected: boolean as a prop to each memoized row. Works in theory, but in practice:

  • The parent (EmbeddedExplorerGrid) has to subscribe to the full selectedItems array to know what to pass to each row → it re-renders on every marquee tick.
  • Even if React.memo bails each row, the parent still runs the body of .map() 200 times and creates 200 fresh prop objects. Cheaper than re-rendering 200 subtrees, but still wasteful.
  • That parent re-render cascades to everything non-memoized in its subtree (contextValue, internal useMemos, etc.).

Alternative 3 — Custom event emitter + local useState per row

Each row subscribes via useEffect to an event emitter and keeps its own useState<boolean> for isSelected. Works, but:

  • That is exactly what useSyncExternalStore does internally, only with proper SSR support (getServerSnapshot), concurrent-mode consistency (tearing under Concurrent Mode), and unmount cleanup.
  • With a useEffect + useState roll-your-own, there is a small lag between the change and the row render (the effect only runs after commit), which can cause visual tearing under rapid updates.
  • useSyncExternalStore guarantees synchronous consistency: the row always sees the current store value, never a stale one.

What useSyncExternalStore does in practice

Its signature is:

useSyncExternalStore<T>(
  subscribe: (onStoreChange: () => void) => () => void,
  getSnapshot: () => T,
  getServerSnapshot?: () => T,
): T

Three functions, React handles the rest:

  1. Calls subscribe once on mount, keeps the teardown for unmount. We are handed a callback (onStoreChange) that we invoke whenever we want subscribers to re-check the snapshot.
  2. Calls getSnapshot on every render and every time onStoreChange fires. It compares the new value to the previous one with Object.is. If they are equal, React bails out the component render — the function body does not even run.
  3. Guarantees consistency: if the value changes mid-render, React re-runs the render with the fresh value (no tearing under Concurrent Mode).

That Object.is on the snapshot is what makes this design work. For useIsItemSelected(id), getSnapshot returns a primitive boolean. Two equal booleans are Object.is-equal → React bails automatically, with no React.memo, no custom comparator, no gymnastics.

The detail that makes it all work: per-id listeners

useSyncExternalStore alone would not be enough if we notified every listener on every change. Imagine: add an item to the selection, notify all rows → each row re-computes its snapshot → React compares, bails for 199 rows, re-renders 1. That is better than the old situation (no tree-wide re-render), but every tick still fires 200 getSnapshot calls.

SelectionStore.setSelectedItems goes further: it diffs the previous and new id Maps (changedIds) and only notifies the listeners of ids whose status actually changed:

const changedIds = new Set<string>();
prevMap.forEach((_, id) => {
  if (!nextMap.has(id)) changedIds.add(id);
});
nextMap.forEach((_, id) => {
  if (!prevMap.has(id)) changedIds.add(id);
});

changedIds.forEach((id) => {
  this.idListeners.get(id)?.forEach((l) => l());
});

Result: if 2 items are added to the selection during a tick, we notify exactly 2 listeners, not 200. React re-renders those 2 rows, and does literally nothing for the other 198 — no render, no getSnapshot, not even an Object.is. Optimal.

If the caller passes a new array reference whose id-set is identical to the previous one (e.g. shift-click over a range that is already fully selected), the store keeps the previous reference and no listener is woken up at all.

One-sentence summary

useSyncExternalStore is the only React primitive that simultaneously (a) subscribes to state living outside React, (b) gives automatic bailout when the scoped value does not change, and (c) guarantees synchronous, tearing-free rendering — exactly the three properties needed so that a marquee tick only re-renders the affected rows.

Implementation details

1. The selection store

File: src/frontend/apps/drive/src/features/explorer/stores/selectionStore.ts

Contains:

  • A SelectionStore class:
    • Internal state: an Item[] (stable snapshot for consumers of the full list) and a Map<string, Item> (O(1) id lookup).
    • Two listener sets:
      • Global listeners for components that show the full list or the count (selection bar, SDK picker selection bar, drag overlay, etc.).
      • Per-id listeners for grid rows that only care about their own isSelected status.
    • setSelectedItems(action) accepts either an array or an updater (same signature as Dispatch<SetStateAction<Item[]>>). Only notifies per-id listeners whose status actually changed — the key point that lets the 197 unaffected rows do nothing.
    • getSelectedItems(), isSelected(id), clear(), subscribe(), subscribeToId().
  • A SelectionStoreContext React context to carry the instance.
  • Ergonomic hooks:
    • useSelectionStore() — returns the instance (for imperative reads inside event handlers).
    • useSelectedItems() — full list, global subscription.
    • useIsItemSelected(id) — boolean scoped to one id, per-id subscription. This is the hook rows and cells use.
    • useHasSelection()count > 0 boolean, useful to show/hide the selection bar without subscribing to the full count.
    • useSelectionCount() — selection size.
    • useSetSelectedItems() — stable setter.
    • useCreateSelectionStore() — creates a local instance via useRef (needed to scope a selection to a subtree, see below).

2. Wiring into GlobalExplorerContext

File: src/frontend/apps/drive/src/features/explorer/components/GlobalExplorerContext.tsx

  • The provider creates a SelectionStore instance via useCreateSelectionStore() and exposes it through <SelectionStoreContext.Provider>.
  • selectedItems, selectedItemsMap and setSelectedItems are removed from GlobalExplorerContext's value. Consumers that needed them now go through useSelectedItems() / useIsItemSelected() / useSetSelectedItems().
  • Effects that used to clear the selection (right-panel open, folder navigation) now call selectionStore.clear().

3. Two selection scopes: global and local

The explorer runs in two modes:

  • App mode — main Drive navigation, one global store created by GlobalExplorerProvider.
  • Embedded mode — used by the "Move to" modal (the user picks a target folder) and by the SDK picker. Each usage has its own local selection — selecting a folder in the move modal must not alter the main explorer's selection.

Solution: EmbeddedExplorer (and its useEmbeddedExplorer hook) creates its own SelectionStore via useCreateSelectionStore() and exposes it through a local SelectionStoreContext.Provider. Because React providers shadow each other naturally, grid cells always read the nearest provider's store without needing to know where the grid is rendered.

4. EmbeddedExplorerGrid refactor

File: src/frontend/apps/drive/src/features/explorer/components/embedded-explorer/EmbeddedExplorerGrid.tsx

  • Removed the selectedItems / setSelectedItems props (the grid reads everything through useSelectionStore() internally).
  • Removed the local selectedItemsMap that was recomputed every render.
  • Removed selectedItemsMap from EmbeddedExplorerGridContextType.
  • Click / context-menu / over handlers are extracted into stable useCallbacks. Selection reads are imperative through selectionStore.getSelectedItems() or selectionStore.isSelected(id) — no subscription, no parent re-render during a marquee.
  • The <tbody> mapping now delegates to a new memoized EmbeddedExplorerGridRow component.

5. EmbeddedExplorerGridRow component

File: src/frontend/apps/drive/src/features/explorer/components/embedded-explorer/EmbeddedExplorerGridRow.tsx

  • Wrapped in React.memo.
  • Reads isSelected via useIsItemSelected(row.original.id) → subscription scoped to this item's id.
  • Receives row, isOvered, onClickRow, onContextMenuRow, onOver as props. These props are stable (the row is stable as long as its data does not change, the callbacks are useCallbacks with primitive deps in the parent).
  • Result: when the selection changes, only the rows whose status flips receive a notification and re-render. The others bail out at the hook level, before the component body even runs.

6. Cell memoization

Files:

  • EmbeddedExplorerGridNameCell.tsx
  • EmbeddedExplorerGridActionsCell.tsx
  • EmbeddedExplorerGridMobileCell.tsx

Each cell is wrapped in React.memo. EmbeddedExplorerGridNameCell (and the useDisableDragGridItem helper in hooks.tsx) no longer read selectedItemsMap from the grid context — they use useIsItemSelected(item.id). They only re-render when their own selection flips.

7. Avoiding cascading re-renders from higher components

The classic trap: if a component high up in the tree (e.g. AppExplorerInner, ExplorerDndProvider) subscribes to the full selection via useSelectedItems(), it re-renders on every marquee tick, and that re-render cascades down to EmbeddedExplorerGrid → rows → cells. All the downstream memoization work is wasted.

To avoid that, parents do not subscribe to the full list when they don't need it:

  • AppExplorerInner no longer reads selectedItems. To decide between the selection bar and the filters it uses useHasSelection(), which only re-renders when the boolean flips (count 0 → 1 or 1 → 0). During a marquee, the boolean flips at most twice — imperceptible.
  • ExplorerDndProvider no longer renders the count directly. The drag overlay and the move confirmation modal are extracted into two small child components (SelectionCountDragOverlay and ExplorerTreeMoveConfirmationModalWithCount) that subscribe to the count via useSelectionCount(). The provider itself subscribes to nothing, so its children do not re-render because of it.
  • useTableKeyboardNavigation — the effect that re-focuses the table after each selection change used [selectedItems] as a dependency. It now uses a direct imperative subscription to selectionStore.subscribe() inside a useEffect: the side effect fires, but the host component (the grid) is not invalidated.

8. Call-site migration

Every file that used to read useGlobalExplorer().selectedItems or .selectedItemsMap has been migrated:

  • AppExplorerInner.tsx
  • AppExplorerGrid.tsx
  • ExplorerSelectionBar.tsx
  • ExplorerDndProvider.tsx
  • ExplorerRightPanelContent.tsx
  • ExplorerRenameItemModal.tsx
  • ExplorerMoveFolderModal.tsx
  • EmbeddedExplorerGridNameCell.tsx + hooks.tsx
  • useTableKeyboardNavigation.ts
  • EmbeddedExplorer.tsx + useEmbeddedExplorer
  • pages/explorer/trash/index.tsx
  • pages/sdk/explorer/index.tsx + SdkPickerFooter

Depending on their needs, those files use either useSelectedItems() (reactive list), useSelectionStore().getSelectedItems() (imperative read inside a handler), or useIsItemSelected(id) / useHasSelection() / useSelectionCount() when a scoped subscription is enough.

What this refactor solves

The reported bug

Marquee drag over a folder with 200+ items is smooth again: stable 60 FPS, no perceivable micro-freeze. On every mouse tick during the marquee:

Before After
Parent grid renders 1 0
<tr> renders 200 = number of rows whose isSelected flips (typically 03)
Heavy cell renders (NameCell, etc.) 200 × 3 columns = number of affected rows × 3
Re-renders of Draggable / Tooltip / ItemIcon 600+ ≈ 09

Positive side effects

  • Single click / Cmd+click / Shift+click: same gains — no more cascading re-renders, only the rows that flipped update.
  • Keyboard navigation (up/down arrows): the table focus effect is now decoupled from the render → more efficient, and more correct (no React dependency on an always-new array reference).
  • Modals that consume the selection (rename, move): imperative reads from the store, no more superfluous subscriptions.
  • Cleaner code: gone is the duplicated selectedItemsMap (once in GlobalExplorerContext, once in EmbeddedExplorerGrid); gone is the comment acknowledging the "Context as a workaround" trick that did not actually work.
  • Clean scoping of local vs global selection: the "Move to" modal and the SDK picker now have a strictly isolated selection, with no risk of leaking into the main explorer.

What was left untouched

  • overedItemIds (the ids of items hovered during a dnd-kit drag) suffers structurally from the same problem: it is a Record<string, boolean> stored in a Context; every update creates a new object and invalidates all consumers. During a DnD drag over a large folder, the same cascading re-render pattern happens. This refactor does not tackle that: it is not the reported bug, and the fix is the exact same pattern (external store
    • useIsItemOvered(id) hook). To be done in a follow-up if the need arises.