Created
May 26, 2026 09:29
-
-
Save mosioc/3644d45f02079be494e6215ea1e4ad9c to your computer and use it in GitHub Desktop.
Large-scale offline-first app with useLiveQuery + state management
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| type Listener = () => void; | |
| type Unsubscribe = () => void; | |
| interface CacheEntry<T> { | |
| data: T; | |
| listeners: Set<Listener>; | |
| dbUnsubscribe: Unsubscribe | null; | |
| subscriberCount: number; | |
| } | |
| /** | |
| * Normalize SQL and stringify params to produce a stable cache key. | |
| */ | |
| export function hashQuery(sql: string, params: unknown[]): string { | |
| const normalizedSQL = sql.replace(/\s+/g, " ").trim(); | |
| return `${normalizedSQL}::${JSON.stringify(params)}`; | |
| } | |
| /** | |
| * Returns true if the two values are meaningfully different. | |
| * Bails early on reference equality, then does a shallow key/value comparison. | |
| */ | |
| function hasChanged<T>(prev: T, next: T): boolean { | |
| if (prev === next) return false; | |
| if (typeof prev !== "object" || typeof next !== "object") { | |
| return prev !== next; | |
| } | |
| if (prev === null || next === null) return prev !== next; | |
| const prevRecord = prev as Record<string, unknown>; | |
| const nextRecord = next as Record<string, unknown>; | |
| const prevKeys = Object.keys(prevRecord); | |
| const nextKeys = Object.keys(nextRecord); | |
| if (prevKeys.length !== nextKeys.length) return true; | |
| for (const key of prevKeys) { | |
| if ( | |
| !Object.prototype.hasOwnProperty.call(nextRecord, key) || | |
| prevRecord[key] !== nextRecord[key] | |
| ) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| export class QueryCache { | |
| private cache = new Map<string, CacheEntry<unknown>>(); | |
| /** | |
| * Subscribe to a cached live query. | |
| * | |
| * @param key - hashed query identifier | |
| * @param setup - called once per cache entry to start the DB listener. | |
| * Receives onData to call with fresh results. | |
| * Must return a teardown function. | |
| * @param onStoreChange - React's callback to notify of external store changes | |
| * @returns a function that unsubscribes this specific listener | |
| */ | |
| subscribe<T>( | |
| key: string, | |
| setup: (onData: (data: T) => void) => Unsubscribe, | |
| onStoreChange: Listener | |
| ): Unsubscribe { | |
| let entry = this.cache.get(key) as CacheEntry<T> | undefined; | |
| if (!entry) { | |
| entry = { | |
| data: undefined as unknown as T, | |
| listeners: new Set(), | |
| dbUnsubscribe: null, | |
| subscriberCount: 0, | |
| }; | |
| const handleData = (newData: T) => { | |
| // Only notify if data actually changed | |
| if (hasChanged(entry!.data, newData)) { | |
| entry!.data = newData; | |
| entry!.listeners.forEach((listener) => listener()); | |
| } | |
| }; | |
| entry.dbUnsubscribe = setup(handleData); | |
| this.cache.set(key, entry); | |
| } | |
| entry.listeners.add(onStoreChange); | |
| entry.subscriberCount += 1; | |
| // Return an unsubscribe function that only removes THIS listener | |
| return () => { | |
| const current = this.cache.get(key) as CacheEntry<T> | undefined; | |
| if (!current) return; | |
| current.listeners.delete(onStoreChange); | |
| current.subscriberCount -= 1; | |
| // If no more listeners, tear down the DB listener and remove the entry | |
| if (current.subscriberCount === 0) { | |
| current.dbUnsubscribe?.(); | |
| this.cache.delete(key); | |
| } | |
| }; | |
| } | |
| /** | |
| * Get the current cached data for a key. Returns undefined if not yet loaded. | |
| */ | |
| getSnapshot<T>(key: string): T | undefined { | |
| const entry = this.cache.get(key); | |
| return entry?.data as T | undefined; | |
| } | |
| /** | |
| * Check if a cache entry exists and has active subscribers. | |
| */ | |
| hasActiveSubscribers(key: string): boolean { | |
| const entry = this.cache.get(key); | |
| return (entry?.subscriberCount ?? 0) > 0; | |
| } | |
| /** | |
| * Force-invalidate a cached query, tearing down its listener. | |
| * Next subscription will re-setup the DB listener. | |
| */ | |
| invalidate(key: string): void { | |
| const entry = this.cache.get(key); | |
| if (!entry) return; | |
| entry.dbUnsubscribe?.(); | |
| this.cache.delete(key); | |
| } | |
| /** | |
| * Invalidate all cached queries. | |
| */ | |
| clear(): void { | |
| this.cache.forEach((entry) => { | |
| entry.dbUnsubscribe?.(); | |
| }); | |
| this.cache.clear(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
screens/ItemsScreen.tsx
(Tying it together with focus-awareness)