Skip to content

fix: sidebar - dashboard data rendering lag #41

Description

@ethnjs

Sidebar — Hover-to-Expand with Pin, and Dashboard-Wide Render Performance

Overview

Two related problems are addressed together because they share the same root cause.

Problem 1 — Sidebar behavior: The sidebar currently toggles expanded/collapsed on click via a chevron button at the bottom. The new behavior is hover-to-expand by default, with a pin chevron that locks the sidebar open permanently (shrinking the content viewport) until unpinned.

Problem 2 — Render performance: sidebarExpanded state lives in TournamentShell, which is the parent of every page in the tournament dashboard. Every hover-in and hover-out triggers a React re-render of the entire page subtree — including every row in the volunteers table, events table, sheets mapping table, and any other data-heavy view. This is the primary cause of jank during sidebar transitions.

The fix restructures sidebar state so that data views are never re-rendered by sidebar interactions, and introduces row virtualization and component memoization as a shared standard for all data-heavy views.


Motivation

The sidebar is in normal document flow — it pushes the content area when it expands (52px → 192px). Currently, sidebarExpanded is owned by TournamentShell and passed down to <Sidebar>, which means the layout's sidebarWidth variable changes on every hover event. Because <main> is a child of TournamentShell, every page's content re-renders on every sidebar state change. On pages with large data sets (volunteers, events, sheets) this causes visible lag during the hover animation.

The sidebar animation itself is a CSS width transition — React has no business re-rendering data rows because of it.


Part 1 — Sidebar Behavior

New interaction model

State Description
Collapsed (default) 52px wide, icons only. Content viewport is full width.
Hover-expanded Sidebar widens to 192px on mouseenter, collapses back on mouseleave. The sidebar overlays the content area — content viewport does not shrink.
Pinned User clicks the chevron. Sidebar stays at 192px regardless of hover. Content viewport shrinks to accommodate. Persisted across page loads (localStorage).
Unpinned User clicks chevron again. Returns to hover-expanded behavior.

Key distinction: overlay vs. push

  • Hover-expanded: sidebar uses position: fixed (or absolute with a high z-index), floating over the content. The content area does not reflow.
  • Pinned: sidebar is in normal document flow, pushing content to the right. This is when the viewport actually shrinks.
    This means the content area only ever reflowing when the user deliberately pins or unpins — not on every hover.

Chevron placement and appearance

The chevron lives at the bottom of the sidebar, same position as the current toggle button. Its visual state:

  • Unpinned: chevron points right (→), dimmed. Tooltip: "Pin sidebar".
  • Pinned: chevron points left (←), accent color. Tooltip: "Unpin sidebar".
    The chevron is always visible when the sidebar is expanded (hovered or pinned). It is hidden when the sidebar is collapsed (only icons showing).

Implementation changes

Sidebar.tsx

Remove the expanded prop and onToggle callback. The sidebar manages its own expanded state internally:

// Two independent pieces of state
const [hovered,  setHovered]  = useState(false);
const [pinned,   setPinned]   = useState(() => {
  // Read from localStorage on mount
  if (typeof window === "undefined") return false;
  return localStorage.getItem("sidebar-pinned") === "true";
});
 
const isExpanded = pinned || hovered;
 
function handleMouseEnter() { setHovered(true);  }
function handleMouseLeave() { setHovered(false); }
function handlePin()        {
  setPinned((v) => {
    const next = !v;
    localStorage.setItem("sidebar-pinned", String(next));
    return next;
  });
}

When pinned changes, notify the layout so it can adjust the content margin:

interface SidebarProps {
  tournamentId: string | number;
  onPinnedChange: (pinned: boolean) => void;
}

onPinnedChange is the only callback the sidebar exposes. Hover state is fully internal.

layout.tsx (TournamentShell)

Replace sidebarExpanded with sidebarPinned:

const [sidebarPinned, setSidebarPinned] = useState(false);
const sidebarWidth = sidebarPinned ? EXPANDED_W : COLLAPSED_W;

sidebarWidth now only changes when the user pins or unpins — not on hover. The content area reflows only then.

Sidebar positioning when unpinned (hover-expanded)

When the sidebar is not pinned, it should expand over the content without pushing it. Change the sidebar's CSS position to fixed when !pinned, and sticky (current behavior) when pinned:

position: pinned ? "sticky" : "fixed",

When position: fixed, the content area must add a left margin equal to COLLAPSED_W (52px) so it isn't obscured by the collapsed sidebar rail. When position: sticky, the content area's left margin comes from the sidebar being in flow.


Part 2 — Dashboard-Wide Render Performance

With Part 1 in place, the only remaining trigger for content re-renders is the sidebarPinned state change, which happens on deliberate user action — not animation frames. But data views still re-render unnecessarily when other unrelated state changes higher in the tree (e.g. tournament data loading, topbar state). The following patterns should be applied to every data-heavy view.

Affected views

Page Components
Volunteers VolunteerCardGrid, volunteer table
Events EventTable, EventCardGrid, EventTimeline, TimeBlocksTable
Sheets Mapping table, sheet config list

1. Memoize all data view components

Wrap every data-heavy component in React.memo. Ensure props passed to them are referentially stable:

// Stable data reference — new array reference won't be created on parent re-render
const volunteers = useMemo(() => rawVolunteers, [rawVolunteers]);
 
// Stable callbacks
const handleRowClick = useCallback((id: string) => { ... }, []);
 
const VolunteerTable = React.memo(({ data, onRowClick }: Props) => {
  // ...
});

2. Isolate panel/modal state from data components

Any state that controls a panel, modal, or popover must not live in a component that is also an ancestor of a data view. Use a dedicated context or co-locate the state with the panel itself, not with the page root.

// Bad — panelOpen re-renders the table
function VolunteersPage() {
  const [panelOpen, setPanelOpen] = useState(false);
  return (
    <>
      <VolunteerTable data={volunteers} /> {/* re-renders on panelOpen change */}
      <VolunteerPanel open={panelOpen} />
    </>
  );
}
 
// Good — table never sees panelOpen
const VolunteerPanelContext = createContext(...);
 
function VolunteersPage() {
  return (
    <VolunteerPanelProvider>
      <VolunteerTable data={volunteers} /> {/* isolated */}
      <VolunteerPanel />                   {/* reads from context */}
    </VolunteerPanelProvider>
  );
}

3. Virtualize all tables

Install the TanStack suite:

npm install @tanstack/react-table @tanstack/react-virtual

Apply useVirtualizer to every table with more than ~30 expected rows. The virtualizer renders only the rows currently visible in the scroll container, plus an overscan buffer:

const rowVirtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => containerRef.current,
  estimateSize: () => 48,  // row height in px — adjust per table
  overscan: 10,
});

When the content area resizes (e.g. sidebar pinned/unpinned), the virtualizer recalculates its visible window automatically via ResizeObserver. No React re-render of row data is triggered by the resize.

Apply to:

  • Volunteer table
  • EventTable
  • TimeBlocksTable
  • Sheet config list (lower priority — fewer rows, but apply for consistency)
  • Sheet mapping table (column mapping rows in the edit view)

4. Use useTransition for non-urgent updates

React 19 is already in use. Wrap sidebar pin/unpin and tab switches in startTransition so they don't block urgent renders (typing in search, scrolling):

const [isPending, startTransition] = useTransition();
 
function handlePin() {
  startTransition(() => setSidebarPinned((v) => !v));
}

Implementation Order

  1. Sidebar state refactor — self-contained in Sidebar.tsx and layout.tsx. No page-level changes.
  2. Panel state isolation — per page, starting with Volunteers (largest data set).
  3. React.memo + stable refs — apply across all data components.
  4. Virtualization — apply table by table, starting with Volunteers and Events.
  5. useTransition — apply to sidebar pin and tab switches.

Out of Scope

  • Card grid virtualization (windowing 2D grids is more complex; defer until card counts make it necessary)
  • Server-side pagination (the current in-memory approach is fine at current data volumes; this is a backend concern if it becomes necessary)
  • Any changes to the Topbar

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestfrontendperformanceCurrent implementation works correctly but is inefficient

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions