Query Sync with Legend State and IndexedDB #
This document outlines the process for implementing synchronization between Legend State observables and IndexedDB for persistent data storage in the webapp.
Overview #
Our webapp uses Legend State for reactive state management combined with IndexedDB for client-side persistence. This setup allows data to persist across browser sessions and provides optimistic updates with backend synchronization.
Architecture #
The sync system consists of three main components:
- Table Configuration (
persist.ts) - Defines table names and database versioning - Observable Creation - Creates synced observables using
syncedDataStateQueryObservable - Initialization (
use-init-sync.ts) - Ensures all observables are properly initialized
Adding a New Table #
When creating a new data table with Legend State and IndexedDB persistence, follow these steps:
Step 1: Define Table Configuration #
In apps/webapp/src/shared/utils/persist.ts:
-
Increment the database version:
// Increment the DB_VERSION when adding a new table const DB_VERSION = 12; // Previously was 11 -
Add the table name constant:
export const TABLE_YOUR_FEATURE = "yourFeature"; -
Add the table name to the tableNames array:
export const getPersistPlugin = () => { if (!persistPluginInstance) { persistPluginInstance = observablePersistIndexedDB({ databaseName: DEFAULT_PERSIST_DB_NAME, version: DB_VERSION, tableNames: [ TABLE_ENTITIES, TABLE_DOCUMENTS, TABLE_INVOICES, // ... existing tables TABLE_YOUR_FEATURE, // Add your new table here ], }); } return persistPluginInstance; };
Step 2: Create the Observable #
Create a new file for your feature (e.g., apps/webapp/src/shared/sync/your-feature.ts):
import type { Observable } from "@legendapp/state";
import { syncedDataStateQueryObservable } from "@/shared/utils/query";
import type { DbState } from "@/shared/sync/types";
import { TABLE_YOUR_FEATURE } from "@/shared/utils/persist";
import { auth$ } from "@/shared/sync/auth";
import { doesCurrentEntityExist$, ui$ } from "@/shared/sync/ui";
// Define your data interface
interface YourFeatureData {
id: string;
name: string;
// ... other properties
}
// Define the state interface extending DbState
interface YourFeatureState extends DbState<YourFeatureData[]> {
// Add any custom methods here
getById: (id: string) => YourFeatureData | undefined;
}
// Create the observable
const yourFeature$: Observable<YourFeatureState> =
syncedDataStateQueryObservable(
TABLE_YOUR_FEATURE,
() => ({
list: getYourFeatureQueryOptions({
entity_id: ui$.currentEntityId.get() || "",
}),
transform: {
list: (data) => data?.data ?? [],
},
persist: true,
enabledQueryObserver: true,
staleTime: 5000,
refetchInterval: 10000,
mode: "set",
}),
{
dependencies: [ui$.currentEntityId],
waitFor: [auth$.isAuthenticated, doesCurrentEntityExist$],
extensions: {
getById: (data$) => (id: string) => {
return data$.get().find((item) => item.id === id);
},
},
}
);
export default yourFeature$;
Step 3: Register in the State Tree #
In apps/webapp/src/shared/sync/index.ts:
import yourFeature$ from "@/shared/sync/your-feature";
export const state$ = observable({
auth: auth$,
db: {
entities: entities$,
documents: documents$,
invoices: invoices$,
yourFeature: yourFeature$, // Add your observable here
// ... other tables
},
ui: ui$,
});
Step 4: Initialize the Observable #
In apps/webapp/src/shared/sync/hooks/use-init-sync.ts, add your observable to the initialization:
const initSync = async (): Promise<void> => {
// ... existing initialization code
try {
// Initialize by directly accessing the data$ properties
state$.db.entities.data$.get();
state$.db.documents.data$.get();
state$.db.invoices.data$.get();
state$.db.yourFeature.data$.get(); // Add this line
// ... rest of initialization
} catch (err) {
// ... error handling
}
};
Updating Existing Tables #
When modifying an existing table structure (adding/removing fields, changing indexes, etc.):
Step 1: Update Database Version #
In apps/webapp/src/shared/utils/persist.ts:
// Increment the DB_VERSION
const DB_VERSION = 12; // Previously was 11
Step 2: Update Initialization #
In apps/webapp/src/shared/sync/hooks/use-init-sync.ts, ensure your observable’s data$.get() is called in the initialization:
const initSync = async (): Promise<void> => {
try {
// Make sure to include your updated observable
state$.db.yourUpdatedFeature.data$.get();
// ... other initializations
} catch (err) {
// ... error handling
}
};
Real-World Examples #
Example 1: Entities Table #
const entities$: Observable<EntitiesState> = syncedDataStateQueryObservable(
TABLE_ENTITIES,
{
list: getGetEntitiesQueryOptions(),
transform: {
list: (data) => data?.data || [],
},
persist: true,
enabledQueryObserver: true,
staleTime: 5000,
refetchInterval: 10000,
mode: 'set',
},
{
extensions: {
getById: (data$) => (id: string) => {
return data$.get().find((entity) => entity.id === id);
},
},
}
);
Example 2: Accounting Data with Dependencies #
const accountingAccounts$: Observable<AccountingAccountsState> =
syncedDataStateQueryObservable(
TABLE_ACCOUNTING_ACCOUNTS,
() => ({
list: getAccountingAccountsQueryOptions(
accountingIntegrations$.data$?.get()?.[0]?.id || '',
{
entity_id: ui$.currentEntityId.get() || '',
}
),
transform: {
list: (data) => data?.data ?? [],
},
persist: true,
enabledQueryObserver: true,
staleTime: 3000,
refetchInterval: 3000,
mode: 'set',
}),
{
dependencies: [ui$.integrationId, ui$.currentEntityId],
waitFor: [auth$.isAuthenticated, doesCurrentEntityExist$],
}
);
Important Notes #
Database Versioning #
- Always increment
DB_VERSIONwhen adding new tables or updating existing table structures - This applies to both creating new tables and modifying existing ones
- This ensures proper IndexedDB schema migration
Development Environment #
- When developing locally, close the browser tab before making table changes
- This prevents version conflicts during development
Naming Conventions #
- Table name constants should be in SCREAMING_SNAKE_CASE
- Observable names should end with
$(e.g.,entities$) - The table name should match the first parameter of
syncedDataStateQueryObservable
Dependencies and Wait Conditions #
- Use
dependenciesarray for observables that should trigger re-queries when changed - Use
waitForarray to ensure required conditions are met before starting queries - Common wait conditions include authentication and entity existence
Error Handling #
- Always include proper error handling in your observables
- Use the logger utility for consistent error reporting
- Consider showing user-friendly error messages with toast notifications
Troubleshooting #
Common Issues #
- Table not found errors: Ensure the table name is added to both the constant and the
tableNamesarray - Data not persisting: Check that
persist: trueis set in the observable configuration - Initialization errors: Verify that the observable’s
data$.get()is called inuse-init-sync.ts - Version conflicts: Make sure to increment
DB_VERSIONwhen making schema changes
Performance Considerations #
- Set appropriate
staleTimeandrefetchIntervalvalues based on data update frequency - Use
waitForconditions to prevent unnecessary queries - Consider using extensions for computed values to avoid recalculation
- Always use
batch()when updating multiple observables (see Performance Optimization section below)
Performance Optimization #
Critical: Use batch() for Multiple State Updates
#
The most important performance optimization for Legend State is using batch() to combine multiple state updates into a single render cycle.
Why Batching Matters #
Without batching, each .set(), .push(), .assign(), or .delete() call triggers a separate re-render of all subscribed components. With batching, multiple updates = 1 render instead of N renders.
import { batch } from '@legendapp/state';
// ❌ BAD - 3 separate renders
function updateUserProfile(name: string, email: string, avatar: string) {
state$.user.name.set(name); // Render 1
state$.user.email.set(email); // Render 2
state$.user.avatar.set(avatar); // Render 3
}
// ✅ GOOD - Single render
function updateUserProfile(name: string, email: string, avatar: string) {
batch(() => {
state$.user.name.set(name);
state$.user.email.set(email);
state$.user.avatar.set(avatar);
}); // Renders once after batch completes
}
When to Use batch()
#
Use batch() whenever you update 2 or more observables in sequence:
- Selection Functions - Updating selected item + related state
- Reset/Clear Operations - Clearing multiple filters or resetting state
- Mutation Handlers - After API calls that update multiple observables
- Bulk Operations - Processing arrays or multiple items
- Entity Switching - Changing entity + related dependent state
Batching in syncedQuery Mutations #
Always batch state updates in mutation handlers:
import { batch } from '@legendapp/state';
import { syncedDataStateQueryObservable } from '@/shared/utils/query';
const items$: Observable<ItemsState> = syncedDataStateQueryObservable(
TABLE_ITEMS,
{
list: getItemsQueryOptions(),
transform: { list: (data) => data?.data ?? [] },
persist: true,
enabledQueryObserver: true,
mode: 'set',
},
{
mutations: {
create: {
mutationFn: async (newItem: ItemInput) => {
const result = await createItem(newItem);
// ✅ Batch all post-mutation state updates
batch(() => {
state$.db.items.data$.push(result);
state$.ui.selectedItemId.set(result.id);
state$.ui.isLoading.set(false);
});
return result;
},
},
},
}
);
Batching with Async Operations #
Important: batch() only works with synchronous code. Batch before and after async operations:
// ❌ WRONG - batch doesn't work across async boundaries
batch(async () => {
const result = await api.call(); // This breaks the batch
state$.data.set(result);
});
// ✅ CORRECT - batch synchronous updates separately
async function processData() {
// Batch: Set loading states
batch(() => {
state$.isLoading.set(true);
state$.error.set(null);
});
// Async operation (not batched)
const result = await api.call();
// Batch: Update with results
batch(() => {
state$.data.set(result);
state$.isLoading.set(false);
state$.lastUpdated.set(Date.now());
});
}
Selection Functions Pattern #
When implementing selection functions that update multiple related observables, always use batch():
import { batch } from '@legendapp/state';
// ✅ GOOD - Batched selection (6 renders → 1 render)
export function selectItemById(itemId: string) {
batch(() => {
const item = state$.db.items.data$.get().find(i => i.id === itemId);
if (!item) {
state$.ui.selectedItemId.set(null);
state$.ui.selectedItemPageId.set(null);
return;
}
state$.ui.selectedItemId.set(itemId);
state$.ui.selectedItemPageId.set(item.pages?.[0]?.id ?? null);
state$.ui.activeDetailPanel.set('info');
state$.ui.error.set(null);
});
}
Reset/Clear Operations Pattern #
Clearing multiple filters or resetting state should always be batched:
import { batch } from '@legendapp/state';
// ✅ GOOD - Batched reset (8 renders → 1 render)
export function resetFilters() {
batch(() => {
state$.ui.filters.status.set([]);
state$.ui.filters.dateRange.set(null);
state$.ui.filters.searchTerm.set('');
state$.ui.filters.tags.set([]);
state$.ui.filters.category.set(null);
state$.ui.filters.sortBy.set('createdAt');
state$.ui.filters.sortOrder.set('desc');
state$.ui.filters.pageSize.set(25);
});
}
Bulk Array Operations #
When pushing multiple items to an array, batch the operations:
import { batch } from '@legendapp/state';
// ❌ BAD - N renders for N items
function addItems(newItems: Item[]) {
newItems.forEach(item => {
state$.items.push(item); // Renders N times
});
}
// ✅ GOOD - Single render
function addItems(newItems: Item[]) {
batch(() => {
newItems.forEach(item => {
state$.items.push(item);
});
}); // Renders once
}
When to Use .get() vs .peek()
#
Use .get() when
#
- Inside computed observables (required for dependency tracking)
- In React components (to trigger re-renders on changes)
- When you need reactive updates
// ✅ Reactive read in computed observable
const filtered$ = computed(() => {
const items = state$.db.items.data$.get(); // Tracks dependency
const searchTerm = state$.ui.searchTerm.get(); // Tracks dependency
return items.filter(item => item.name.includes(searchTerm));
});
Use .peek() when
#
- Reading values in event handlers (one-time reads)
- Initialization code (doesn’t need reactivity)
- Logging/debugging (no subscription needed)
- Inside
batch()when reading values for comparison - When you explicitly want to avoid creating a reactive subscription
// ✅ Non-reactive read in event handler
function handleClick() {
const currentValue = state$.count.peek(); // No subscription
if (currentValue > 10) {
doSomething();
}
}
// ✅ Non-reactive read in batch
function updateIfChanged(newValue: string) {
batch(() => {
const current = state$.value.peek(); // No subscription
if (current !== newValue) {
state$.value.set(newValue);
state$.lastModified.set(Date.now());
}
});
}
Nested Batching #
Nested batch() calls are safe and will be coalesced into a single update:
function outerFunction() {
batch(() => {
state$.a.set(1);
innerFunction(); // Also uses batch internally
state$.b.set(2);
});
}
function innerFunction() {
batch(() => {
state$.c.set(3);
state$.d.set(4);
});
}
// All 4 updates trigger a single render
Common Pitfalls to Avoid #
- Don’t batch single updates - adds unnecessary overhead:
// ❌ Unnecessary
batch(() => state$.count.set(5));
// ✅ Just use set directly
state$.count.set(5);
- Don’t forget to batch in mutation handlers - this is where most gains are:
// ❌ Multiple renders after API call
const result = await api.create(data);
state$.items.push(result);
state$.selectedId.set(result.id);
// ✅ Single render
const result = await api.create(data);
batch(() => {
state$.items.push(result);
state$.selectedId.set(result.id);
});
- Batch at the right level - don’t create multiple small batches when you can create one large batch:
// ❌ Multiple small batches
items.forEach(item => {
batch(() => {
state$.items.push(item);
state$.count.set(prev => prev + 1);
});
});
// ✅ One large batch
batch(() => {
items.forEach(item => {
state$.items.push(item);
state$.count.set(prev => prev + 1);
});
});
Performance Impact #
Proper use of batch() can provide:
- 30-50% reduction in unnecessary re-renders on hot paths
- 10x faster bulk array operations (100 items: 100 renders → 1 render)
- 75-90% reduction in renders for complex operations (selection, filtering, resets)
Further Reading #
For more Legend State best practices and patterns, see:
- Legend State Best Practices (if available)
- Legend State Official Documentation
This documentation should be updated as the sync system evolves and new patterns emerge.