Skip to content

Why not use @tanstack/query for the react hooks? #46

@Sheraff

Description

@Sheraff

I'm working on making a "starter template" for offline-first PWAs and I was wondering if there was a reason to have gone full-custom for the query hooks? The @tanstack/query library is pretty mature and nice to use.

If I were to give the sales pitch, here's what I could say

  • it's framework-agnostic™ (I never tried anything other than the react implementation)
  • it's well typed
  • it's well known, people are likely to be already familiar with it
  • it provides a global cache manager for de-duping simultaneous queries / invalidating
  • it does a deep merge of its results to minimize re-renders
  • it provides a couple utilities (focus manager, online manager) that can be useful for "offline-conscious" apps
  • it has hooks more patterns (mutations, infinite / paginated queries, react suspense)
  • it has devtools
  • it has good documentation

On the other hand

  • it's quite a good amount of code (13.7kB gzipped), though for an app that already loads 2Mb of wasm it should be ok
  • I'm not exactly sure if it's as fast as your current implementation

I tried a quick implementation of useQuery, here's what that could look like:

import { useQuery, useQueryClient, hashKey } from "@tanstack/react-query"
import { useDB, type CtxAsync } from "@vlcn.io/react"
import { useEffect, useState, useRef } from "react"

type DBAsync = CtxAsync["db"]

type UpdateType =
	/** INSERT */
	| 18
	/** UPDATE */
	| 23
	/** DELETE */
	| 9

/**
 * Not really useful, this is just to increase the cache hit rate.
 */
function sqlToKey(sql: string) {
	return sql.replace(/\s+/g, " ")
}

/**
 * Rely on react-query's cacheManager to
 * - know which queries are active
 * - force invalidation of "currently in-use" queries
 *
 * Rely on vlcn RX to
 * - know which tables are used by a query
 * - know when to invalidate queries
 */
export function useCacheManager(dbName: string) {
	const [tables, setTables] = useState<string[]>([])
	const queryMap = useRef(
		new Map<
			string,
			{
				tables: string[]
				updateTypes: UpdateType[]
				queryKey: unknown[]
			}
		>(),
	)

	const client = useQueryClient()
	const ctx = useDB(dbName)

	useEffect(() => {
		/** not the cleanest implementation, could execute less code if it was outside of react */
		const cleanup = tables.map((table) =>
			ctx.rx.onRange([table], (updates) => {
				queryMap.current.forEach((query) => {
					if (!query.tables.includes(table)) return
					if (!updates.some((u) => query.updateTypes.includes(u))) return
					client.invalidateQueries({ queryKey: query.queryKey, exact: true })
				})
			}),
		)
		return () => {
			for (const dispose of cleanup) {
				dispose()
			}
		}
	}, [tables])

	useEffect(() => {
		const cacheManager = client.getQueryCache()
		/** count how many queries are relying on each table */
		const tableCountMap = new Map<string, number>()

		const cacheUnsubscribe = cacheManager.subscribe((event) => {
			if (event.type === "observerAdded") {
				const key = hashKey(event.query.queryKey)
				if (queryMap.current.has(key)) return
				/** add to Map early, so we know if it has been deleted by `observerRemoved` before `usedTables` resolves */
				queryMap.current.set(key, {
					updateTypes: event.query.queryKey[2],
					queryKey: event.query.queryKey,
					tables: [],
				})
				usedTables(ctx.db, event.query.queryKey[1]).then((usedTables) => {
					const query = queryMap.current.get(key)
					if (!query) return
					queryMap.current.set(key, { ...query, tables: usedTables })
					for (const table of usedTables) {
						if (!tableCountMap.has(table)) {
							tableCountMap.set(table, 1)
							setTables((tables) => [...tables, table])
						} else {
							tableCountMap.set(table, tableCountMap.get(table)! + 1)
						}
					}
				})
			} else if (event.type === "observerRemoved") {
				const key = hashKey(event.query.queryKey)
				const query = queryMap.current.get(key)
				if (!query) return
				queryMap.current.delete(key)
				for (const table of query.tables) {
					if (!tableCountMap.has(table)) continue
					const count = tableCountMap.get(table)!
					if (count === 1) {
						tableCountMap.delete(table)
						setTables((tables) => tables.filter((t) => t !== table))
					} else {
						tableCountMap.set(table, count - 1)
					}
				}
			}
		})
		return cacheUnsubscribe
	}, [])
}

let queryId = 0

export function useDbQuery<
	TQueryFnData = unknown,
	// TError = DefaultError, // TODO
	TData = TQueryFnData[],
>({
	dbName,
	query,
	select,
	bindings = [],
	updateTypes = [18, 23, 9],
}: {
	dbName: string
	query: string
	select?: (data: TQueryFnData[]) => TData
	bindings?: ReadonlyArray<string>
	updateTypes?: Array<UpdateType>
}) {
	const ctx = useDB(dbName)
	const queryKey = [dbName, sqlToKey(query), updateTypes, bindings]

	return useQuery({
		queryKey,
		queryFn: async () => {
			const statement = await ctx.db.prepare(query)
			statement.bind(bindings)
			const [releaser, transaction] = await ctx.db.imperativeTx()
			const transactionId = queryId++
			transaction.exec(/*sql*/ `SAVEPOINT use_query_${transactionId};`)
			statement.raw(false)
			try {
				const data = (await statement.all(transaction)) as TQueryFnData[]
				transaction.exec(/*sql*/ `RELEASE use_query_${transactionId};`).then(releaser, releaser)
				return data
			} catch (e) {
				transaction.exec(/*sql*/ `ROLLBACK TO use_query_${transactionId};`).then(releaser, releaser)
				throw e
			}
		},
		select,
	})
}

async function usedTables(db: DBAsync, query: string): Promise<string[]> {
	const sanitized = query.replaceAll("'", "''")
	const rows = await db.execA(/*sql*/ `
		SELECT tbl_name
		FROM tables_used('${sanitized}') AS u
		JOIN sqlite_master ON sqlite_master.name = u.name
		WHERE u.schema = 'main';
	`)
	return rows.map((r) => r[0])
}

If you're curious, here's the "starter template" I'm talking about https://github.com/Sheraff/root

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions