UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

174 lines (154 loc) 5.17 kB
import type { Async } from "alepha"; import type { DurationLike } from "alepha/datetime"; import { type DependencyList, useCallback, useRef, useState } from "react"; import type { ActionContext } from "./useAction.ts"; import { useAction } from "./useAction.ts"; /** * Hook for declarative data fetching with automatic execution and refetch. * * Thin wrapper over {@link useAction}: it pre-applies `runOnInit: true`, * exposes the last result as `data`, and provides a stable `refetch()` to * re-run the query on demand. For optimistic mutations and side-effects, * use {@link useAction} directly — `useQuery` is for the read path. * * Caching, request deduplication, and AbortSignal cancellation come from * `useAction` + `HttpClient`. There is no separate cache layer — pass * `localCache` to your `HttpClient.fetch()`/`fetchAction()` call inside * the query handler if you want per-call caching. * * @example Basic * ```tsx * const client = useInject(HttpClient); * const { data, loading, error, refetch } = useQuery({ * handler: async ({ signal }) => { * const res = await client.fetch("/api/users", { request: { signal } }); * return res.data; * }, * }, []); * ``` * * @example Re-fetch when a dep changes * ```tsx * const { data } = useQuery({ * handler: async () => api.getUser(userId), * }, [userId]); * ``` * * @example Polling * ```tsx * const { data } = useQuery({ * handler: async () => api.getStatus(), * runEvery: [5, "seconds"], * }, []); * ``` */ export function useQuery<Result>( options: UseQueryOptions<Result>, deps: DependencyList, ): UseQueryReturn<Result> { const enabled = options.enabled !== false; const [data, setData] = useState<Result | undefined>(options.initialData); // Tracks whether the auto-run has completed at least once, so we can keep // `loading` true on the very first render — before `runOnInit`'s effect // fires — instead of briefly reporting `loading: false` with no data. const settledRef = useRef(false); const action = useAction<[], Result>( { id: options.id, handler: options.handler, runOnInit: enabled, runEvery: options.runEvery, debounce: options.debounce, onError: options.onError, onSuccess: async (result) => { settledRef.current = true; setData(result); if (options.onSuccess) { await options.onSuccess(result); } }, }, deps, ); const refetch = useCallback(() => action.run(), [action.run]); // `loading` is true while a fetch is in flight, AND during the initial gap // between first render and the auto-run effect — so consumers can render a // skeleton immediately instead of flashing an empty/not-found state. Only // applies to enabled queries with no `initialData` seed; an error settles // it (the error path keeps the caller's `onError` re-throw semantics). const loading = action.loading || (enabled && options.initialData === undefined && !settledRef.current && action.error === undefined); return { data, loading, error: action.error, refetch, cancel: action.cancel, }; } // --------------------------------------------------------------------------------------------------------------------- export interface UseQueryOptions<Result> { /** * Async query handler. Receives an {@link ActionContext} with an * AbortSignal that fires on unmount, dependency change, or `cancel()`. */ handler: (context: ActionContext) => Async<Result>; /** * Optional identifier (used in lifecycle events for debugging/analytics). */ id?: string; /** * If `false`, skip automatic execution on mount and dep change. Use * `refetch()` to trigger manually. Defaults to `true`. */ enabled?: boolean; /** * Initial value for `data` before the first successful fetch. */ initialData?: Result; /** * Re-run periodically. See {@link useAction} `runEvery`. */ runEvery?: DurationLike; /** * Debounce delay in milliseconds. See {@link useAction} `debounce`. */ debounce?: number; /** * Called on success with the resolved value. */ onSuccess?: (result: Result) => void | Promise<void>; /** * Custom error handler. If provided, prevents default error re-throw. */ onError?: (error: Error) => void | Promise<void>; } export interface UseQueryReturn<Result> { /** * The last successful result. `undefined` until the first fetch resolves * (or the value of `initialData` if provided). */ data: Result | undefined; /** * Loading state — `true` while a fetch is in flight, and also from the * first render until the initial auto-run settles (for enabled queries * with no `initialData`), so a skeleton can render without a flash. */ loading: boolean; /** * Error from the last failed fetch, if any. */ error?: Error; /** * Re-run the query. The previous in-flight request, if any, is aborted. */ refetch: () => Promise<Result | undefined>; /** * Abort the in-flight request without scheduling another. */ cancel: () => void; }