One hook for async data

One useQuery hook returns data, error, loading and refetch.

From frontend-patterns, “Async Data Fetching Hook”.

Retry after a failure

Tick Fail and refetch both. Untick, then press Retry.

Ad-hoc fetchBad
loading: false
useQueryGood
loading: false
export function useQuery<T>(
  key: string,
  fetcher: () => Promise<T>,
  options?: UseQueryOptions<T>
) {
  const [data, setData] = useState<T | null>(null)
  const [error, setError] = useState<Error | null>(null)
  const [loading, setLoading] = useState(false)

  const refetch = useCallback(async () => {
    setLoading(true)
    setError(null)
    try {
      const result = await fetcher()
      setData(result)
      options?.onSuccess?.(result)
    } catch (err) {
      setError(err as Error)
      options?.onError?.(err as Error)
    } finally {
      setLoading(false)
    }
  }, [fetcher, options])

  useEffect(() => {
    if (options?.enabled !== false) refetch()
  }, [key, refetch, options?.enabled])

  return { data, error, loading, refetch }
}

Wait for inputs with enabled

Press Sign in, then Sign out.

userId: null
No enabledBad
useQuery(key, fetcher)
enabled: id !== nullGood
useQuery(key, fetcher, options)
const fetchWatchlist = useCallback(
  () => api.get(`/users/${userId}/watchlist`),
  [userId]
)
const options = useMemo(() => ({ enabled: userId !== null }), [userId])

const { data, loading, error } = useQuery(`watchlist:${userId}`, fetchWatchlist, options)

Stable inputs, stable requests

Press Mount both and compare the request counts.

Inline fetcherBad
useQuery("markets", () => get(), { onSuccess })
MemoizedGood
useCallback + useMemo
const fetchMarkets = useCallback(() => api.get("/markets"), [])
const options = useMemo(() => ({
  onSuccess: (markets) => log(`Fetched ${markets.length} markets`),
  onError: (err) => log(`Failed: ${err.status}`),
}), [])

const { data, error, loading, refetch } = useQuery("markets", fetchMarkets, options)

Why it matters, pros and cons

Why it matters

Every fetch has four states. Written once, every screen handles them the same.

Pros

  • finally always ends the spinner.
  • Retry never shows a stale error.
  • enabled waits for missing inputs.

Cons

  • Inline fetchers or options refetch in a loop.
  • No cache, dedupe or cancellation.
  • Real apps: Server Components, TanStack Query or SWR.