
疑似TanStack Queryを作成
Reactで某サイトのキャンペーンサイトを作成することになっているのですが、React以外はnpm install 禁止という制限がクライアントから言い渡された。
そんなときは以下の疑似TanStack Queryを作成して使用すれば問題解決(?)です。
useQuery.tsx
import { useEffect, useMemo, useRef, useState } from 'react'
type QueryKey = readonly unknown[]
type QueryStatus = 'idle' | 'loading' | 'success' | 'error'
type QueryState<T> = {
data: T | undefined
error: unknown
status: QueryStatus
updatedAt: number
promise: Promise<T> | null
abortController: AbortController | null
}
type UseQueryOptions<T> = {
queryKey: QueryKey
queryFn: (ctx: { signal: AbortSignal }) => Promise<T>
enabled?: boolean
staleTime?: number
retry?: number
retryDelayMs?: number
}
type UseQueryResult<T> = {
data: T | undefined
error: unknown
isLoading: boolean
isFetching: boolean
isError: boolean
isSuccess: boolean
status: QueryStatus
refetch: () => Promise<T>
updatedAt: number
}
const queryCache = new Map<string, QueryState<unknown>>()
const listeners = new Map<string, Set<() => void>>()
const notify = (key: string) => {
const set = listeners.get(key)
if (!set) return
set.forEach(fn => fn())
}
const subscribe = (key: string, fn: () => void) => {
const set = listeners.get(key) ?? new Set()
set.add(fn)
listeners.set(key, set)
return () => {
set.delete(fn)
if (set.size === 0) listeners.delete(key)
}
}
const getKeyString = (queryKey: QueryKey) => JSON.stringify(queryKey)
const getOrInitState = <T,>(key: string): QueryState<T> => {
const existing = queryCache.get(key) as QueryState<T> | undefined
if (existing) return existing
const init: QueryState<T> = {
data: undefined,
error: undefined,
status: 'idle',
updatedAt: 0,
promise: null,
abortController: null
}
queryCache.set(key, init as QueryState<unknown>)
return init
}
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
const fetchWithRetry = async <T,>(
run: () => Promise<T>,
retry: number,
retryDelayMs: number
) => {
let attempt = 0
while (true) {
try {
return await run()
} catch (e) {
if (attempt >= retry) throw e
attempt += 1
await sleep(retryDelayMs)
}
}
}
const startFetch = async <T,>(
key: string,
opts: Required<Pick<UseQueryOptions<T>, 'queryFn' | 'retry' | 'retryDelayMs'>>
) => {
const state = getOrInitState<T>(key)
// 既に進行中なら同じ Promise を返す
if (state.promise) return state.promise
// 古い fetch を止める(同 key で refetch したときなど)
state.abortController?.abort()
const ac = new AbortController()
state.abortController = ac
state.status = state.data === undefined ? 'loading' : state.status
state.error = undefined
notify(key)
const run = async () => {
const data = await opts.queryFn({ signal: ac.signal })
return data
}
const p = fetchWithRetry(run, opts.retry, opts.retryDelayMs)
.then(data => {
// abort 済みなら state を書き換えない
if (ac.signal.aborted) return data
state.data = data
state.error = undefined
state.status = 'success'
state.updatedAt = Date.now()
return data
})
.catch(err => {
if (ac.signal.aborted) throw err
state.error = err
state.status = 'error'
throw err
})
.finally(() => {
// promise は必ずクリア
state.promise = null
// abortController は最新のみ維持してもよいが、ここでは残す
notify(key)
})
state.promise = p
return p
}
export const useQuery = <T,>(options: UseQueryOptions<T>): UseQueryResult<T> => {
const {
queryKey,
queryFn,
enabled = true,
staleTime = 0,
retry = 0,
retryDelayMs = 300
} = options
const key = useMemo(() => getKeyString(queryKey), [queryKey])
const [, force] = useState(0)
const mountedRef = useRef(true)
const state = getOrInitState<T>(key)
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (staleTime === 0) return
const id = setInterval(() => {
setNow(Date.now())
}, Math.min(staleTime, 1000))
return () => clearInterval(id)
}, [staleTime])
const isStale = state.updatedAt === 0 || now - state.updatedAt > staleTime
const refetch = async () => {
const p = startFetch<T>(key, { queryFn, retry, retryDelayMs })
return p
}
useEffect(() => {
mountedRef.current = true
const unsub = subscribe(key, () => {
if (!mountedRef.current) return
force(x => x + 1)
})
return () => {
mountedRef.current = false
unsub()
}
}, [key])
useEffect(() => {
if (!enabled) return
// stale なら取りに行く(初回もここで拾う)
if (isStale) {
startFetch<T>(key, { queryFn, retry, retryDelayMs }).catch(() => {
// 表示側が error を読むのでここは握りつぶし
})
}
}, [key, enabled, isStale, queryFn, retry, retryDelayMs])
const isFetching = state.promise != null
const isLoading = state.status === 'loading' || (state.data === undefined && isFetching)
const isError = state.status === 'error'
const isSuccess = state.status === 'success'
return {
data: state.data,
error: state.error,
isLoading,
isFetching,
isError,
isSuccess,
status: state.status,
refetch,
updatedAt: state.updatedAt
}
}App.tsx
import { useQuery } from './useQuery'
type Product = {
id: number
title: string
price: number
}
function App() {
const { data, isLoading, error, refetch } = useQuery<Product[]>({
queryKey: ['products'],
queryFn: async ({ signal }) => {
const res = await fetch('https://dummyjson.com/products?select=title,price&delay=1000', { signal })
if (!res.ok) throw new Error('fetch failed')
const json = await res.json()
return json.products as Product[]
},
staleTime: 10000,
})
return (
<>
{isLoading && <p>Loading...</p>}
{error && !isLoading && <p>Something went wrong.</p>}
{!isLoading && !error && data && data.length > 0 && (
<>
<button type="button" onClick={() => refetch()}>
再読み込み
</button>
<ul>
{data.map(product => (
<li key={product.id}>
{product.title} (${product.price})
</li>
))}
</ul>
</>
)}
</>
)
}
export default App以上、疑似TanStack Queryなんて作成しても中途半端なものになるので、本家のものを使おうという記事でした。


