Legend State Indexeddb Sync

State Management: TanStack Query + Legend State Observables #

This document outlines how the webapp manages API data and client-side state using TanStack Query as the source of truth, with Legend State observables as reactive mirrors for synchronous access.

Overview #

The webapp uses a two-tier architecture for state management:

  1. TanStack Query (Tier 1) — Source of truth for all API data. Handles fetching, caching, polling, and cache invalidation.
  2. Legend State Observables (Tier 2) — Lightweight reactive mirrors of TanStack Query cache data. Provide synchronous access (.peek()) outside React and reactive subscriptions (.get(), use$()) inside components and computed observables.

Data flows in one direction: API → TanStack Query cache → store-dispatcher → observables → components.

Architecture #

The system consists of four components:

  1. Generated API hooks (shared/lib/api/_generated/) — Orval-generated TanStack Query hooks from OpenAPI specs
  2. Custom wrapper hooks (shared/lib/api/<resource>/read.ts) — Configure staleTime, polling, and enabled conditions
  3. Store-dispatcher (shared/lib/api/store-dispatcher.ts) — Subscribes to TanStack Query cache events and mirrors data to observables
  4. Observable stores (shared/lib/api/<resource>/store.ts) — Simple observable() instances holding current data
┌──────────────────┐     ┌───────────────────┐     ┌──────────────────┐
│  Generated Hook  │────▶│  TanStack Query   │────▶│ Store-Dispatcher │
│  (useGetInvoice) │     │  Cache            │     │ (cache listener) │
└──────────────────┘     └───────────────────┘     └────────┬─────────┘
                                                            │
                                                            ▼
┌──────────────────┐     ┌───────────────────┐     ┌──────────────────┐
│  Component       │◀────│  Computed         │◀────│  Observable      │
│  (use$, .peek()) │     │  (review.tsx)     │     │  (invoice$)      │
└──────────────────┘     └───────────────────┘     └──────────────────┘

Observable Stores #

Each data type has its own store file with a simple observable. These follow a consistent naming pattern:

Single-Item Stores (Detail Views) #

Used for the currently selected/viewed resource:

// shared/lib/api/invoices/store.ts
import { observable } from '@legendapp/state';
import type { Invoice } from '@/shared/lib/api/_generated/bonsAPI.schemas';

export const invoice$ = observable<Invoice | null>(null);
// shared/lib/api/bank-statements/store.ts
export const bankStatement$ = observable<BankStatement | null>(null);
// shared/lib/api/direct-expenses/store.ts
export const directExpense$ = observable<DirectExpenseResponse | null>(null);

List Stores (Collections) #

Used for lists of resources with stale-data tracking:

// shared/lib/api/extractions/store.ts
export const extractions$ = observable<Extraction[]>([]);
export const staleExtractions$ = observable<Extraction[]>([]);
export const isShowStaleExtractions$ = observable<boolean>(false);
export const loadedExtractionTimestamps$ = observable<Record<string, number>>({});
// shared/lib/api/documents/store.ts
export const documents$ = observable<DocumentResponse[]>([]);
export const staleDocuments$ = observable<DocumentResponse[]>([]);
export const isShowStaleDocuments$ = observable<boolean>(false);
// shared/lib/api/entities/store.ts
export const entities$ = observable<Entity[]>([]);

Naming Convention #

  • Observable names use the resource name followed by $ (e.g., invoice$, bankStatement$, extractions$)
  • Single-item stores are singular (invoice$), list stores are plural (extractions$)
  • Stores are imported directly from their store file, not re-exported through a central barrel

Store Helper Functions #

Stores can export utility functions for accessing or mutating data without React context:

// shared/lib/api/extractions/store.ts

export function getExtractionById(id: string): Extraction | undefined {
  return extractions$.peek().find((e: Extraction) => e.id === id);
}

export function setExtractionsCache(updater: (prev: Extraction[]) => Extraction[]) {
  const qc = getQueryClient();
  const cache = qc.getQueryCache();
  const queries = cache.findAll({ queryKey: ['/api/v1/extractions'] });

  for (const query of queries) {
    qc.setQueryData(query.queryKey, (old: any) => {
      if (!old?.data) return old;
      return { ...old, data: updater(old.data) };
    });
  }
}

export function removeFromStaleExtractions(extractionId: string): void {
  staleExtractions$.set((prev) => prev.filter((e) => e.id !== extractionId));
}

Store-Dispatcher: Cache → Observable Sync #

The store-dispatcher is the central hub that subscribes to TanStack Query cache events and automatically mirrors them to observable stores.

// shared/lib/api/store-dispatcher.ts

export function initAllStores(queryClient: QueryClient): () => void {
  return queryClient.getQueryCache().subscribe((event) => {
    if (event.type !== 'updated' || event.action.type !== 'success') {
      return;
    }

    const key = event.query.queryKey;
    const prefix = key[0] as string;
    const data = event.query.state.data as any;

    // Route cache events to the appropriate observable store
    if (prefix === '/api/v1/documents') {
      if (typeof key[1] === 'object') {
        documents$.set(data?.data ?? []);
      }
    } else if (prefix === '/api/v1/extractions') {
      if (typeof key[1] === 'object') {
        extractions$.set(data?.data ?? []);
      }
    } else if (prefix.startsWith('/api/v1/invoices/')) {
      invoice$.set(data);
    } else if (prefix.startsWith('/api/v1/bank-statements/')) {
      bankStatement$.set(data);
    } else if (prefix.startsWith('/api/v1/direct-expenses/')) {
      directExpense$.set(data);
    }
  });
}

The dispatcher is initialized once in the TanStack Query provider:

// shared/providers/tanstack-query-provider.tsx

export function TanstackQueryProvider({ children, config }: TanstackQueryProviderProps) {
  const queryClient = getQueryClient(config);

  useEffect(() => {
    const unsubscribeEntity = initEntityStore(queryClient);
    const unsubscribeAll = initAllStores(queryClient);
    return () => {
      unsubscribeEntity();
      unsubscribeAll();
    };
  }, [queryClient]);

  return (
    <QueryClientProvider client={queryClient}>
      <ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
    </QueryClientProvider>
  );
}

API Call Pattern #

Generated Hooks #

API hooks are auto-generated from OpenAPI specs via Orval into shared/lib/api/_generated/. These provide useQuery/useMutation hooks and standalone fetcher functions.

Custom Wrapper Hooks #

Components use custom wrapper hooks that configure caching, polling, and enable conditions:

// shared/lib/api/invoices/read.ts

export function useInvoice(
  extractionId: string,
  entityId: string,
  options?: { enabled?: boolean }
) {
  const isTutorialActive = use$(ui$.isTutorialActive);
  const enabled = options?.enabled ?? (!!extractionId && !!entityId);

  return useGetInvoice(extractionId, { entity_id: entityId }, {
    query: {
      enabled,
      staleTime: 30000,
      refetchInterval: isTutorialActive ? false : 30000,
    },
  });
}

Other wrapper hooks follow the same pattern:

// shared/lib/api/bank-statements/read.ts
export function useBankStatement(extractionId: string, entityId: string, options?) { ... }

// shared/lib/api/direct-expenses/read.ts
export function useDirectExpense(extractionId: string, entityId: string, options?) { ... }

Incremental Loading (Extractions) #

For large collections, the extractions hook implements incremental loading — fetching only deltas after the initial load:

// shared/lib/api/extractions/read.ts

export function useExtractions(entityId: string, filters?: ExtractionFilters) {
  const queryClient = useQueryClient();
  const latestUpdatedAt = useRef<number>(0);

  return useQuery({
    queryKey: extractionKeys.all(entityId),
    queryFn: async () => {
      const existing = queryClient.getQueryData(queryKey);
      const isIncremental = !!existing?.data?.length;

      if (isIncremental) {
        // Fetch only updates since last poll
        const response = await getExtractions({
          updated_after: latestUpdatedAt.current,
          n_per_page: N_PER_PAGE,
        });
        return { data: mergeById(existing.data, processItems(response.data ?? [])) };
      }

      // Full load: fetch first page immediately, remaining pages in background
      const firstResponse = await getExtractions({ n_per_page: N_PER_PAGE, page_number: 0 });
      const firstBatch = processItems(firstResponse.data ?? []);

      if (firstBatch.length < (firstResponse.total_count ?? 0)) {
        // Background-fetch remaining pages and merge into cache
        Promise.all(remainingPages.map(fetchPage)).then((responses) => {
          queryClient.setQueryData(queryKey, (current) => ({
            data: mergeById(current.data, processItems(responses.flat())),
          }));
        });
      }

      return { data: firstBatch };
    },
    staleTime: 3000,
    refetchInterval: isTutorialActive ? false : 3000,
    enabled: !!entityId,
    placeholderData: keepPreviousData,
  });
}

Query Keys #

Query keys follow a hierarchical pattern for targeted cache invalidation:

// shared/lib/api/invoices/keys.ts
export const invoiceKeys = {
  all: ['/api/v1/invoices'] as const,
  byExtraction: (extractionId: string) => [`/api/v1/invoices/${extractionId}`] as const,
  detail: (invoiceId: string) => [`/api/v1/invoices/${invoiceId}`] as const,
} as const;

// shared/lib/api/extractions/keys.ts
export const extractionKeys = {
  all: (entityId: string) => [...getGetExtractionsQueryKey({ entity_id: entityId })],
  detail: (extractionId: string) => ['/api/v1/extractions', extractionId] as const,
} as const;

Complete Data Flow Example: Invoice #

1. Component triggers fetch #

// features/review/index.tsx
export function ReviewModule() {
  const selectedExtraction = use$(selectedExtraction$);

  // Conditionally fetch invoice when an invoice-type extraction is selected
  useInvoice(selectedExtraction?.id || '', entityId, {
    enabled: !!selectedExtraction && isInvoiceType(selectedExtraction.type),
  });
}

2. TanStack Query calls the API #

useInvoice()useGetInvoice()GET /api/v1/invoices/{extractionId}?entity_id={entityId} → response stored in TQ cache.

3. Store-dispatcher mirrors to observable #

The cache update event fires. The dispatcher matches the query key prefix /api/v1/invoices/ and calls invoice$.set(data).

4. Components consume the observable #

// Direct usage with use$()
const selectedInvoice = use$(invoice$);

// Synchronous access in event handlers
const invoiceId = invoice$.peek()?.id;

// In computed observables (shared/state/review.tsx)
export const extractionBoundingBoxes$ = computed<BoundingBox>(() => {
  const extraction = selectedExtraction$.get();
  switch (extraction?.type) {
    case ExtractionType.AP_BILL:
      return invoice$.get()?.invoice_data?.metadata?.bboxes;
    case ExtractionType.BANK_STATEMENT:
      return bankStatement$.get()?.data?.metadata?.bboxes;
    case ExtractionType.DIRECT_EXPENSE:
      return directExpense$.get()?.data?.metadata?.bboxes;
  }
});

Mutation Pattern #

Hook-based mutations (inside React) #

// shared/lib/api/invoices/update.ts

export function useUpdateInvoiceData() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ invoiceId, invoiceData, entityId, extractionId }) => {
      const newInvoiceData = {
        ...invoiceData,
        id: v4(),
        invoice_id: invoiceId,
        entity_id: entityId,
        action_type: InvoiceDataActionType.UPDATE,
      } as InvoiceData;

      return createInvoiceData(invoiceId, newInvoiceData, { entity_id: entityId });
    },
    onSuccess: (_data, variables) => {
      // Immediate cache update to prevent flicker
      queryClient.setQueryData(
        invoiceKeys.byExtraction(variables.extractionId),
        (old: any) => (old ? { ...old, data: _data } : old)
      );

      // Invalidate to refetch fresh data
      queryClient.invalidateQueries({ queryKey: invoiceKeys.byExtraction(variables.extractionId) });
      queryClient.invalidateQueries({ queryKey: extractionKeys.all(variables.entityId) });
    },
  });
}

Optimistic updates (extractions) #

// shared/lib/api/extractions/update.ts

export function useUpdateExtractionStatusMutation() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ extractionId, status, entityId }) => {
      return await putExtraction(extractionId, { entity_id: entityId, status }, { entity_id: entityId });
    },
    onMutate: async ({ extractionId, status, entityId }) => {
      // Cancel in-flight queries
      await queryClient.cancelQueries({ queryKey: extractionKeys.all(entityId) });
      const previous = queryClient.getQueryData(extractionKeys.all(entityId));

      // Optimistic update in TQ cache → store-dispatcher auto-syncs to observable
      queryClient.setQueryData(extractionKeys.all(entityId), (old: any) => ({
        ...old,
        data: old.data.map((e: any) =>
          e.id === extractionId ? { ...e, status, updated_at: Date.now() } : e
        ),
      }));

      return { previous };
    },
    onError: (_error, { entityId }, context) => {
      // Rollback on error
      if (context?.previous) {
        queryClient.setQueryData(extractionKeys.all(entityId), context.previous);
      }
    },
    onSettled: (_data, _err, { extractionId, entityId }) => {
      queryClient.invalidateQueries({ queryKey: extractionKeys.all(entityId) });
      removeFromStaleExtractions(extractionId);
    },
  });
}

Non-hook mutations (outside React) #

For code that runs outside React component lifecycle (event handlers, utilities):

// shared/lib/api/invoices/update.ts

export async function updateInvoiceData(
  invoiceId: string,
  invoiceData: Partial<InvoiceData>,
  entityId: string,
  extractionId?: string
): Promise<void> {
  const newInvoiceData = { ...invoiceData, id: v4(), entity_id: entityId } as InvoiceData;

  await createInvoiceData(invoiceId, newInvoiceData, { entity_id: entityId });

  const queryClient = getQueryClient();
  await Promise.all([
    queryClient.invalidateQueries({ queryKey: invoiceKeys.byExtraction(extractionId || invoiceId) }),
    queryClient.invalidateQueries({ queryKey: extractionKeys.all(entityId) }),
  ]);
}

Store Reset on Entity Switch #

When the user switches entities, all stores must be cleared to prevent cross-entity data leakage:

// shared/lib/api/store-reset.ts

export function resetAllStores(): void {
  entities$.set([]);
  documents$.set([]);
  extractions$.set([]);
  invoice$.set(null);
  bankStatement$.set(null);
  directExpense$.set(null);

  resetStaleDocumentsState();
  resetStaleExtractionsState();
  loadedExtractionTimestamps$.set({});
}

Computed State Composition #

The main review state file (shared/state/review.tsx) composes all stores into rich computed observables:

// shared/state/review.tsx
import { batch, computed, observable } from '@legendapp/state';
import { invoice$ } from '@/shared/lib/api/invoices/store';
import { bankStatement$ } from '@/shared/lib/api/bank-statements/store';
import { directExpense$ } from '@/shared/lib/api/direct-expenses/store';
import { extractions$ } from '@/shared/lib/api/extractions/store';
import { documents$ } from '@/shared/lib/api/documents/store';

// UI selection state
export const selectedExtractionId$ = observable<string | null>(null);

// Derived: currently selected extraction
export const selectedExtraction$ = computed(() => {
  return extractions$.get().find((e) => e.id === selectedExtractionId$.get()) ?? null;
});

// Derived: filtered extractions with comprehensive filtering
export const filteredExtractions$ = computed(() => {
  const allExtractions = extractions$.get();
  // ... single-pass filtering with Set-based O(1) lookups
});

Adding a New Resource #

To add a new resource type (e.g., receipt):

Step 1: Create the store #

// shared/lib/api/receipts/store.ts
import { observable } from '@legendapp/state';
import type { Receipt } from '@/shared/lib/api/_generated/bonsAPI.schemas';

export const receipt$ = observable<Receipt | null>(null);

Step 2: Create query keys #

// shared/lib/api/receipts/keys.ts
export const receiptKeys = {
  all: ['/api/v1/receipts'] as const,
  byExtraction: (extractionId: string) => [`/api/v1/receipts/${extractionId}`] as const,
} as const;

Step 3: Create the wrapper hook #

// shared/lib/api/receipts/read.ts
import { useGetReceipt } from '@/shared/lib/api/_generated/receipt/receipt';

export function useReceipt(extractionId: string, entityId: string, options?: { enabled?: boolean }) {
  const enabled = options?.enabled ?? (!!extractionId && !!entityId);

  return useGetReceipt(extractionId, { entity_id: entityId }, {
    query: {
      enabled,
      staleTime: 30000,
      refetchInterval: 30000,
    },
  });
}

Step 4: Register in store-dispatcher #

// shared/lib/api/store-dispatcher.ts — add a new route
} else if (prefix.startsWith('/api/v1/receipts/')) {
  receipt$.set(data);
}

Step 5: Add to store-reset #

// shared/lib/api/store-reset.ts
import { receipt$ } from '@/shared/lib/api/receipts/store';

export function resetAllStores(): void {
  // ... existing resets
  receipt$.set(null);
}

Step 6: Use in components #

import { invoice$ } from '@/shared/lib/api/invoices/store';
import { receipt$ } from '@/shared/lib/api/receipts/store';

// In a React component
const receipt = use$(receipt$);

// In a computed observable
const data = receipt$.get()?.data;

// In an event handler
const receiptId = receipt$.peek()?.id;

Legend State Best Practices #

Use batch() for Multiple State Updates #

When updating 2+ observables, wrap them in batch() to coalesce into a single render:

import { batch } from '@legendapp/state';

// Single render instead of 3
batch(() => {
  selectedExtractionId$.set(newId);
  selectedExtractionPageId$.set(pageId);
  selectedDocumentPageId$.set(docPageId);
});

batch() only works with synchronous code. For async operations, batch before and after:

batch(() => {
  isLoading$.set(true);
  error$.set(null);
});

const result = await api.call();

batch(() => {
  data$.set(result);
  isLoading$.set(false);
});

.get() vs .peek() #

Method Creates subscription Use in
.get() Yes computed(), React components via use$()
.peek() No Event handlers, callbacks, one-time reads
// .get() — reactive, tracks dependencies
const filtered$ = computed(() => {
  const items = extractions$.get();  // Re-runs when extractions change
  return items.filter(/* ... */);
});

// .peek() — non-reactive, one-time read
function handleClick() {
  const id = invoice$.peek()?.id;  // No subscription created
  doSomething(id);
}

Polling Configuration #

Resource staleTime refetchInterval Notes
Extractions (list) 3s 3s Frequently changing, incremental loading
Documents (list) 3s 3s Frequently changing
Invoice (detail) 30s 30s Changes less often
Bank statement (detail) 30s 30s Changes less often
Direct expense (detail) 30s 30s Changes less often

Polling is disabled during tutorial mode (isTutorialActive ? false : interval).

Troubleshooting #

Common Issues #

  1. Observable not updating after API call: Check that the store-dispatcher has a route for the query key prefix
  2. Stale data after entity switch: Ensure resetAllStores() includes your new store
  3. Component not re-rendering: Use .get() or use$() instead of .peek() for reactive reads
  4. Data showing from previous entity: The store-reset must clear all stores on entity switch
  5. Mutation not reflected in UI: Invalidate the correct query keys after mutation so TQ refetches → dispatcher syncs → observable updates