UNPKG

react-async

Version:

React component for declarative promise resolution and data fetching

215 lines (214 loc) 8.64 kB
import { useCallback, useDebugValue, useEffect, useMemo, useRef, useReducer } from "react"; import globalScope, { MockAbortController, noop } from "./globalScope"; import { neverSettle, ActionTypes, init, dispatchMiddleware, reducer as asyncReducer, } from "./reducer"; function useAsync(arg1, arg2) { const options = typeof arg1 === "function" ? { ...arg2, promiseFn: arg1, } : arg1; const counter = useRef(0); const isMounted = useRef(true); const lastArgs = useRef(undefined); const lastOptions = useRef(options); const lastPromise = useRef(neverSettle); const abortController = useRef(new MockAbortController()); const { devToolsDispatcher } = globalScope.__REACT_ASYNC__; const { reducer, dispatcher = devToolsDispatcher } = options; const [state, _dispatch] = useReducer(reducer ? (state, action) => reducer(state, action, asyncReducer) : asyncReducer, options, init); const dispatch = useCallback(dispatcher ? action => dispatcher(action, dispatchMiddleware(_dispatch), lastOptions.current) : dispatchMiddleware(_dispatch), [dispatcher]); const { debugLabel } = options; const getMeta = useCallback((meta) => ({ counter: counter.current, promise: lastPromise.current, debugLabel, ...meta, }), [debugLabel]); const setData = useCallback((data, callback = noop) => { if (isMounted.current) { dispatch({ type: ActionTypes.fulfill, payload: data, meta: getMeta(), }); callback(); } return data; }, [dispatch, getMeta]); const setError = useCallback((error, callback = noop) => { if (isMounted.current) { dispatch({ type: ActionTypes.reject, payload: error, error: true, meta: getMeta(), }); callback(); } return error; }, [dispatch, getMeta]); const { onResolve, onReject } = options; const handleResolve = useCallback(count => (data) => count === counter.current && setData(data, () => onResolve && onResolve(data)), [setData, onResolve]); const handleReject = useCallback(count => (err) => count === counter.current && setError(err, () => onReject && onReject(err)), [setError, onReject]); const start = useCallback(promiseFn => { if ("AbortController" in globalScope) { abortController.current.abort(); abortController.current = new globalScope.AbortController(); } counter.current++; return (lastPromise.current = new Promise((resolve, reject) => { if (!isMounted.current) return; const executor = () => promiseFn().then(resolve, reject); dispatch({ type: ActionTypes.start, payload: executor, meta: getMeta(), }); })); }, [dispatch, getMeta]); const { promise, promiseFn, initialValue } = options; const load = useCallback(() => { const isPreInitialized = initialValue && counter.current === 0; if (promise) { start(() => promise) .then(handleResolve(counter.current)) .catch(handleReject(counter.current)); } else if (promiseFn && !isPreInitialized) { start(() => promiseFn(lastOptions.current, abortController.current)) .then(handleResolve(counter.current)) .catch(handleReject(counter.current)); } }, [start, promise, promiseFn, initialValue, handleResolve, handleReject]); const { deferFn } = options; const run = useCallback((...args) => { if (deferFn) { lastArgs.current = args; start(() => deferFn(args, lastOptions.current, abortController.current)) .then(handleResolve(counter.current)) .catch(handleReject(counter.current)); } }, [start, deferFn, handleResolve, handleReject]); const reload = useCallback(() => { lastArgs.current ? run(...lastArgs.current) : load(); }, [run, load]); const { onCancel } = options; const cancel = useCallback(() => { onCancel && onCancel(); counter.current++; abortController.current.abort(); isMounted.current && dispatch({ type: ActionTypes.cancel, meta: getMeta(), }); }, [onCancel, dispatch, getMeta]); /* These effects should only be triggered on changes to specific props */ /* eslint-disable react-hooks/exhaustive-deps */ const { watch, watchFn } = options; useEffect(() => { if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) { lastOptions.current = options; load(); } }); useEffect(() => { lastOptions.current = options; }, [options]); useEffect(() => { if (counter.current) cancel(); if (promise || promiseFn) load(); }, [promise, promiseFn, watch]); useEffect(() => () => { isMounted.current = false; }, []); useEffect(() => () => cancel(), []); /* eslint-enable react-hooks/exhaustive-deps */ useDebugValue(state, ({ status }) => `[${counter.current}] ${status}`); if (options.suspense && state.isPending && lastPromise.current !== neverSettle) { // Rely on Suspense to handle the loading state throw lastPromise.current; } return useMemo(() => ({ ...state, run, reload, cancel, setData, setError, }), [state, run, reload, cancel, setData, setError]); } export class FetchError extends Error { constructor(response) { super(`${response.status} ${response.statusText}`); this.response = response; /* istanbul ignore next */ if (Object.setPrototypeOf) { // Not available in IE 10, but can be polyfilled Object.setPrototypeOf(this, FetchError.prototype); } } } const parseResponse = (accept, json) => (res) => { if (!res.ok) return Promise.reject(new FetchError(res)); if (typeof json === "boolean") return json ? res.json() : res; return accept === "application/json" ? res.json() : res; }; function isEvent(e) { return typeof e === "object" && "preventDefault" in e; } /** * * @param {RequestInfo} resource * @param {RequestInit} init * @param {FetchOptions} options * @returns {AsyncState<T, FetchRun<T>>} */ function useAsyncFetch(resource, init, { defer, json, ...options } = {}) { const method = resource.method || (init && init.method); const headers = resource.headers || (init && init.headers) || {}; const accept = headers["Accept"] || headers["accept"] || (headers.get && headers.get("accept")); const doFetch = (input, init) => globalScope.fetch(input, init).then(parseResponse(accept, json)); const isDefer = typeof defer === "boolean" ? defer : ["POST", "PUT", "PATCH", "DELETE"].indexOf(method) !== -1; const fn = isDefer ? "deferFn" : "promiseFn"; const identity = JSON.stringify({ resource, init, isDefer, }); const promiseFn = useCallback((_, { signal }) => { return doFetch(resource, { signal, ...init }); }, [identity] // eslint-disable-line react-hooks/exhaustive-deps ); const deferFn = useCallback(function ([override], _, { signal }) { if (!override || isEvent(override)) { return doFetch(resource, { signal, ...init }); } if (typeof override === "function") { const { resource: runResource, ...runInit } = override({ resource, signal, ...init }); return doFetch(runResource || resource, { signal, ...runInit }); } const { resource: runResource, ...runInit } = override; return doFetch(runResource || resource, { signal, ...init, ...runInit }); }, [identity] // eslint-disable-line react-hooks/exhaustive-deps ); const state = useAsync({ ...options, [fn]: isDefer ? deferFn : promiseFn, }); useDebugValue(state, ({ counter, status }) => `[${counter}] ${status}`); return state; } const unsupported = () => { throw new Error("useAsync requires React v16.8 or up. Upgrade your React version or use the <Async> component instead."); }; export default useEffect ? useAsync : unsupported; export const useFetch = useEffect ? useAsyncFetch : unsupported;