Skip to content

Instantly share code, notes, and snippets.

@aleclarson
Last active October 18, 2025 11:38
Show Gist options
  • Save aleclarson/914a784b7a27a2b5b66358b9f9e0622e to your computer and use it in GitHub Desktop.
Save aleclarson/914a784b7a27a2b5b66358b9f9e0622e to your computer and use it in GitHub Desktop.
Remix 3 Query primitive
import { RoutePattern } from '@remix-run/route-pattern'
import { queryCache } from './query.ts'
const userProfileRoute = new RoutePattern('/users/:id')
type UserProfile = {
name?: string
email?: number
biography?: string
avatar?: string
}
export function UserProfile(this: Remix.Handle) {
const query = queryCache(this)
return (props: { id: string; columns?: string[] }) => {
const selectedColumns = props.columns?.join(',') ?? ''
const { isLoading, isError, data, error } = query({
queryKey: [userProfileRoute.source, props.id, selectedColumns],
queryFn: (signal) =>
// In a serious project, this would be type-safe.
fetch(
userProfileRoute.href({ id: props.id }, { columns: selectedColumns }),
{ signal },
).then((res) => res.json()) as Promise<UserProfile>,
})
if (isLoading) {
data satisfies undefined
error satisfies undefined
return <div>Loading...</div>
}
if (isError) {
return (
<div>
Error: {error instanceof Error ? error.message : String(error)}
</div>
)
}
data satisfies UserProfile
error satisfies undefined
return <div>User profile: {JSON.stringify(data)}</div>
}
}
import { JsonValue } from 'type-fest'
export function queryCache(component: Remix.Handle) {
const queryCache = new Map<string, RemixQuery>()
const cacheKeys = new Set<string>()
let flushScheduled = false
const trackQueryAccess = (cacheKey: string) => {
cacheKeys.add(cacheKey)
// Once the component update is complete, clear the cache of unused queries.
if (!flushScheduled) {
flushScheduled = true
component.queueTask(() => {
for (const query of queryCache.values()) {
const cacheKey = query['cacheKey']
if (!cacheKeys.has(cacheKey)) {
query[Symbol.dispose]()
queryCache.delete(cacheKey)
}
}
cacheKeys.clear()
flushScheduled = false
})
}
}
function query<T>({
queryKey,
queryFn,
}: {
queryKey: JsonValue[]
queryFn: (signal: AbortSignal) => PromiseLike<T>
}) {
const cacheKey = JSON.stringify(queryKey)
trackQueryAccess(cacheKey)
const cachedQuery = queryCache.get(cacheKey)
if (cachedQuery) {
return cachedQuery as TaggedRemixQuery<T>
}
const ctrl = new AbortController()
const query = new RemixQuery<T>(cacheKey, ctrl, async () => {
if (query.isLoading) return
query.isLoading = true
try {
const data = await queryFn(
AbortSignal.any([component.signal, ctrl.signal]),
)
query.isSuccess = true
query.data = data
} catch (error) {
if (!isAbortError(error)) {
query.isError = true
query.error = error
}
}
query.isLoading = false
component.update()
})
queryCache.set(cacheKey, query)
component.queueTask(query.refresh)
return query as TaggedRemixQuery<T>
}
return query
}
export class RemixQuery<T = any> {
constructor(
protected readonly cacheKey: string,
protected readonly ctrl: AbortController,
public readonly refresh: () => Promise<void>,
) {}
isLoading = false
isError = false
isSuccess = false
data: T | undefined
error: unknown;
[Symbol.dispose]() {
this.ctrl.abort()
}
}
export type TaggedRemixQuery<T> = RemixQuery<T> &
(LoadingQuery | ErrorQuery | SuccessQuery<T>)
type LoadingQuery = {
isLoading: true
isError: false
isSuccess: false
data: undefined
error: undefined
}
type ErrorQuery = {
isError: true
isLoading: false
isSuccess: false
data: undefined
error: unknown
}
type SuccessQuery<T> = {
isSuccess: true
isLoading: false
isError: false
data: T
error: undefined
}
function isAbortError(error: unknown): error is Error & { name: 'AbortError' } {
return error instanceof Error && error.name === 'AbortError'
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment