Reactで疑似TanStack Queryを作成する方法

疑似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なんて作成しても中途半端なものになるので、本家のものを使おうという記事でした。

疑似TanStack Queryのサンプル