# Pinia Store Architecture in RomM v2: Modular State Management for High-Performance Galleries

> Explore the Pinia store architecture in RomM v2. Discover how four specialized stores modularize state management for high-performance galleries, ROM caching, and UI restoration.

- Repository: [The RomM Project/romm](https://github.com/rommapp/romm)
- Tags: architecture
- Published: 2026-07-05

---

**RomM v2 uses four specialized Pinia stores—`v2GalleryRoms`, `v2GallerySelection`, `v2ScrollRestoration`, and `v2FocusRestoration`—to manage sparse window-based ROM caching, multi-selection state, and UI restoration in a modular, high-performance architecture.**

The frontend rewrite in RomM v2 (available at `rommapp/romm`) replaces the monolithic state management of v1 with a targeted Pinia store architecture. These stores handle distinct concerns—gallery data, selection, scroll position, and focus—enabling efficient virtualized grids and seamless navigation. This architecture leverages Vue 3's reactivity while maintaining strict separation from legacy v1 stores.

## Core Pinia Stores in RomM v2

### v2GalleryRoms – Sparse ROM Cache and Gallery Context

Located in [`frontend/src/v2/stores/galleryRoms.ts`](https://github.com/rommapp/romm/blob/main/frontend/src/v2/stores/galleryRoms.ts), this store maintains a sparse, window-based cache of ROMs rather than loading entire collections. It tracks the current gallery context (platform, collection, virtual collection, smart collection, or search) and manages pagination via `charIndex` and `romIdIndex`.

Core state includes:

- `currentPlatform`, `currentCollection`, `currentVirtualCollection`, `currentSmartCollection`, `currentSearch`
- `byPosition`: A `Map<number, SimpleRom>` linking positions to ROM data
- `loadedWindows`, `pendingWindows`, `failedWindows`: Sets tracking fetch status
- `orderBy`, `orderDir`: Sorting parameters

Key actions:

- `setCurrentPlatform()`, `setCurrentCollection()`, `setOrderBy()`: Context switchers that trigger `resetGallery()` and `invalidateWindows()`
- `fetchInitialMetadata()`: Retrieves total counts and filter indices
- `fetchWindowAt(position)`, `fetchRomAt(position)`: On-demand data loading with `WINDOW_SIZE = 72`
- `cancelFetchAt()`: Uses `AbortController` instances stored in `inFlightControllers` to cancel stale requests

### v2GallerySelection – Multi-Select State Management

Defined in [`frontend/src/v2/stores/gallerySelection.ts`](https://github.com/rommapp/romm/blob/main/frontend/src/v2/stores/gallerySelection.ts), this store handles multi-selection independently from the ROM cache. It stores full `SimpleRom` objects in a `Map<id, SimpleRom>` to avoid re-lookup after window eviction.

State:

- `selected`: Map of ROM IDs to objects
- `lastSelectedPosition`: Anchor for shift-click range selections

Actions:

- `toggle(rom, position)`: Single selection toggle
- `toggleRange(position, getRomAt)`: Shift-click range selection using the anchor
- `selectAllLoaded()`, `clear()`, `removeIds()`

Getters:

- `enabled`, `count`, `roms`, `ids`

### v2ScrollRestoration – Route-Aware Scroll Persistence

This store ([`frontend/src/v2/stores/scrollRestoration.ts`](https://github.com/rommapp/romm/blob/main/frontend/src/v2/stores/scrollRestoration.ts)) persists scroll positions for virtual scrollers across route changes.

State:

- `positions: Map<string, number>` (routeFullPath → scrollTop)

Actions:

- `save(routeFullPath, scrollTop)`: Stores position before navigation
- `restore(routeFullPath)`: Returns saved position or null
- `clear(routeFullPath)`: Removes specific entry

### v2FocusRestoration – Keyboard Navigation State

Found in [`frontend/src/v2/stores/focusRestoration.ts`](https://github.com/rommapp/romm/blob/main/frontend/src/v2/stores/focusRestoration.ts), this enables gamepad and keyboard navigation to resume after route changes.

State:

- `keys: Map<string, string>` (routeFullPath → focusKey)

Actions:

- `save(routeFullPath, focusKey)`: Stores last focused tile
- `restore(routeFullPath)`: Retrieves focus key for re-focus

## Store Interactions and Data Flow

The stores communicate through well-defined actions rather than direct state mutation:

1. **Gallery Context Changes**: When users switch platforms or collections, `v2GalleryRoms` actions like `setCurrentPlatform()` reset the cache via `resetGallery()` and fetch initial metadata through `fetchInitialMetadata()`.

2. **Virtual Scrolling**: Components call `getRomAt(position)` from `v2GalleryRoms`. If data is missing, they trigger `fetchRomAt(position)` or `fetchWindowAt(position)`, which updates the `byPosition` Map reactively.

3. **Selection Handling**: Tile components invoke `v2GallerySelection.toggle(rom, position)` on click. For range selections, `toggleRange(position, galleryRoms.getRomAt)` uses the ROM store's getter to fetch boundary items.

4. **Navigation Restoration**: Before route leave, `scrollRestoration.save()` and `focusRestoration.save()` cache UI state. On mount, `restore()` methods reinitialize the scroller and focus target.

5. **Request Deduplication**: `v2GalleryRoms` maintains a `Map<string, AbortController>` to track in-flight requests, ensuring rapid filter changes cancel outdated fetches via `cancelFetchAt()`.

## Implementation Details and Performance Optimizations

**Windowed Caching Strategy**: Instead of loading thousands of ROMs, `v2GalleryRoms` fetches windows of 72 items (matching the backend pagination limit). This reduces initial load time and memory pressure significantly.

**Reactive Map Usage**: Both `byPosition` and `selected` use native JavaScript `Map` objects. Vue 3's reactive Map support ensures component updates trigger only for specific key changes, crucial for virtualized grids rendering hundreds of cells.

**Abort Controller Management**: The store centralizes network cancellation through `inFlightControllers`. When `fetchWindowAt()` initiates a request, it stores an `AbortController`; subsequent calls to `cancelFetchAt()` or new fetches abort stale requests, preventing race conditions.

**Immutable Store Boundaries**: Selection stores retain full ROM objects to survive window eviction, while gallery stores maintain sparse references. This separation prevents selection loss during aggressive cache trimming.

## Practical Code Examples

Accessing the ROM store and initializing gallery data:

```typescript
import { useStore as useGalleryRoms } from '@/stores/roms'

const galleryRoms = useGalleryRoms()

onMounted(() => {
  // Load first window and metadata (total count, filters)
  galleryRoms.fetchInitialMetadata()
})

```

Lazy-loading ROM data in virtual scroller components:

```typescript
function getRom(position: number) {
  const rom = galleryRoms.getRomAt(position)
  if (!rom) {
    // Triggers fetch; store handles deduplication
    galleryRoms.fetchRomAt(position)
  }
  return rom
}

```

Handling selection toggles in tile components:

```typescript
import useGallerySelection from '@/stores/gallerySelection'

const selection = useGallerySelection()

function onTileClick(rom: SimpleRom, position: number) {
  selection.toggle(rom, position)
}

```

Implementing shift-click range selection:

```typescript
function onTileShiftClick(position: number) {
  // galleryRoms.getRomAt passed as resolver
  selection.toggleRange(position, (p) => galleryRoms.getRomAt(p))
}

```

Saving scroll position before route navigation:

```typescript
import useScrollRestoration from '@/stores/scrollRestoration'

function onBeforeRouteLeave(to, from, next) {
  const container = document.querySelector('.gallery-scroller') as HTMLElement
  useScrollRestoration().save(from.fullPath, container.scrollTop)
  next()
}

onMounted(() => {
  const saved = useScrollRestoration().restore(route.fullPath)
  if (saved !== null) {
    const container = document.querySelector('.gallery-scroller') as HTMLElement
    container.scrollTop = saved
  }
})

```

Persisting and restoring focus for keyboard navigation:

```typescript
import useFocusRestoration from '@/stores/focusRestoration'

function onTileFocus(e: FocusEvent) {
  const focusKey = (e.target as HTMLElement).dataset.focusKey
  if (focusKey) useFocusRestoration().save(route.fullPath, focusKey)
}

onMounted(() => {
  const key = useFocusRestoration().restore(route.fullPath)
  if (key) {
    const el = document.querySelector(`[data-focus-key="${key}"]`) as HTMLElement
    el?.focus()
  }
})

```

## Summary

- **Four specialized stores** (`v2GalleryRoms`, `v2GallerySelection`, `v2ScrollRestoration`, `v2FocusRestoration`) replace the monolithic v1 architecture in `rommapp/romm`
- **Sparse window-based caching** in `v2GalleryRoms` loads only 72-item windows on demand, minimizing memory usage
- **Independent selection state** stores full ROM objects to survive cache eviction and enable shift-click range selection
- **Navigation restoration** persists scroll positions and focus keys per route using simple Map-based stores
- **AbortController integration** prevents race conditions during rapid gallery context switches
- **Vue 3 Map reactivity** ensures precise component updates without full re-renders in virtualized grids

## Frequently Asked Questions

### How does v2GalleryRoms handle pagination without loading all ROMs?

The store implements a sparse cache using a `Map<number, SimpleRom>` called `byPosition`. It fetches data in 72-item windows via `fetchWindowAt()` when the virtual scroller requests specific positions. This windowed approach, defined in [`frontend/src/v2/stores/galleryRoms.ts`](https://github.com/rommapp/romm/blob/main/frontend/src/v2/stores/galleryRoms.ts), keeps memory usage constant regardless of collection size.

### Why does v2GallerySelection store full ROM objects instead of just IDs?

Storing complete `SimpleRom` objects in the `selected` Map ensures selections persist even when `v2GalleryRoms` evicts windows from its sparse cache. This decoupling prevents selection loss during aggressive memory management and eliminates the need to re-fetch ROM metadata for the selection bar.

### How does RomM v2 cancel outdated API requests when switching galleries quickly?

The `v2GalleryRoms` store maintains an `inFlightControllers` Map that tracks `AbortController` instances for each pending fetch. When contexts change rapidly, `cancelFetchAt()` or new fetch actions abort previous requests, preventing race conditions and reducing server load.

### What is the difference between v2 stores and the legacy v1 stores?

Stores prefixed with `v2` (located in `frontend/src/v2/stores/`) belong to the rewrite and follow a modular, domain-specific architecture. Legacy v1 stores remain frozen in `frontend/src/stores/` for backward compatibility. The v2 naming convention allows both implementations to coexist during migration, with v2 stores specifically optimized for virtualized galleries and windowed data fetching.