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:
- TanStack Query (Tier 1) — Source of truth for all API data. Handles fetching, caching, polling, and cache invalidation.
- 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:
- Generated API hooks (
shared/lib/api/_generated/) — Orval-generated TanStack Query hooks from OpenAPI specs - Custom wrapper hooks (
shared/lib/api/<resource>/read.ts) — Configure staleTime, polling, and enabled conditions - Store-dispatcher (
shared/lib/api/store-dispatcher.ts) — Subscribes to TanStack Query cache events and mirrors data to observables - Observable stores (
shared/lib/api/<resource>/store.ts) — Simpleobservable()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 #
- Observable not updating after API call: Check that the store-dispatcher has a route for the query key prefix
- Stale data after entity switch: Ensure
resetAllStores()includes your new store - Component not re-rendering: Use
.get()oruse$()instead of.peek()for reactive reads - Data showing from previous entity: The store-reset must clear all stores on entity switch
- Mutation not reflected in UI: Invalidate the correct query keys after mutation so TQ refetches → dispatcher syncs → observable updates