A Peek at useRequest hook

Aaron Zhang
Pixel and Ink
Published in
7 min readAug 8, 2022

Introduction

useRequest is a powerful, well-encapsulated hook from a React hook library ahooks to manage async data fetching. When there is multiple async logic in a single component in React, we will deal with a bunch of useStateand useEffect hooks, which makes it complicated to call APIs.

What it probably looks like with native React hooks:

// Component.ts
const [ data, setData ] = useState<object>(defaultData)
const [ isLoading, setIsloading ] = useState<boolean>(false)
useEffect(() => {
setIsloading(true)
request = service.fetchData(...)
setData(...)
handlerError(...)
setIsloading(false)
}, [])

With the help of useRequest, we can simplify our code:

import { useRequest } from ‘ahooks’const { data, run: request, loading, error } = useRequest(service.serviceA, options)

Main features

useRequest provides sufficient enough functionalities for network request scenarios in React projects including:

  • Automatic/manual request
  • Polling
  • Debounce
  • Throttling
  • Refresh on window focus
  • Error retry
  • Loading delay
  • SWR(stale-while-revalidate)
  • Caching

A Glance on Basic Usage

Loading delay

Set the delay time for loading to become true

const { loading, data } = useRequest(getUsername, {
loadingDelay: 300 //Set the delay time for loading to become true
});
return <div>{ loading ? 'Loading...' : data }</div>

Polling

By setting options.pollingInterval , enter the polling mode, useRequest will periodically trigger service execution.

const { data, run, cancel } = useRequest(getUsername, {
pollingInterval: 3000,//will periodically trigger service execution.
});

Refresh on window focus

the request will be refreshed when the browser is refocus and revisible.

const { data } = useRequest(getUsername, {
refreshOnWindowFocus: true,
});

Debounce & Throttling

Enter the debounce mode by setting options.debounceWait / options.throttleWait. At this time, if run or runAsync is triggered frequently, the request will be executed with the debounce/throttle strategy.

const { data, run } = useRequest(getUsername, {
debounceWait: 300,
throttleWait: 300,
manual: true
});

Cache & SWR

If options.cacheKey is set, useRequest will cache the successful data . The next time the component is initialized, if there is cached data, it will return the cached data first, and then send a new request in background, which is the ability of SWR.

async function getArticle(): Promise<{ data: string; time: number }> {
console.log('cacheKey');
return new Promise((resolve) => {
setTimeout(() => {
resolve({
data: Mock.mock('@paragraph'),
time: new Date().getTime(),
});
}, 1000);
});
}
const Article = () => {
const { data, loading } = useRequest(getArticle, {
cacheKey: 'cacheKey-demo',
});
if (!data && loading) {
return <p>Loading</p>;
}
return (
<>
<p>Background loading: {loading ? 'true' : 'false'}</p>
<p>Latest request time: {data?.time}</p>
<p>{data?.data}</p>
</>
);
}

Error retry

By setting options.retryCount , set the number of error retries, useRequest will retry after it fails.

const { data, run } = useRequest(getUsername, {
retryCount: 3,
});

Design Pattern

useRequest has two main modules that work together to serve its functionality: the main Fetch class and plugins

The Plugin module uses varieties of different plugins, each of which only works for a specific function.

Fetch module on the other hand is even more simple — to implement the Fetch class which aggregates all plugins to this hook to make it robust and easy to maintain.

Source code

Fetch - the core

Structure of Fetch class:

export default class Fetch<TData, TParams extends any[]> {
pluginImpls: PluginReturn<TData, TParams>[];
count: number = 0; state: FetchState<TData, TParams> = {
loading: false,
params: undefined,
data: undefined,
error: undefined,
};
constructor(
public serviceRef: MutableRefObject<Service<TData, TParams>>,
public options: Options<TData, TParams>,
public subscribe: Subscribe,
public initState: Partial<FetchState<TData, TParams>> = {},
) {
this.state = {
...this.state,
loading: !options.manual,
...initState,
};
}
setState(s: Partial<FetchState<TData, TParams>> = {}) {...} runPluginHandler(event: keyof PluginReturn<TData, TParams>, ...rest: any[]) {...} async runAsync(...params: TParams): Promise<TData> {...} run(...params: TParams) {...} cancel() {...} refresh() {...} refreshAsync() {...} mutate(data?: TData | ((oldData?: TData) => TData | undefined)) {...}

Most of its API is provided for the user to call such as runrunAsynccancelrefreshrefreshAsyncmutate, while runPluginHandlersetState are for internal use.

1. pluginImpls

As per the properties of Fetch class, we can see it has a pluginImpls property, from its type PluginReturn<TData, TParams>[] it seems to contain results of all plugins after execution.

export interface PluginReturn<TData, TParams extends any[]> {
onBefore?: (params: TParams) =>
| ({
stopNow?: boolean;
returnNow?: boolean;
} & Partial<FetchState<TData, TParams>>)
| void;

onRequest?: (
service: Service<TData, TParams>,
params: TParams,
) => {
servicePromise?: Promise<TData>;
};

onSuccess?: (data: TData, params: TParams) => void;
onError?: (e: Error, params: TParams) => void;
onFinally?: (params: TParams, data?: TData, e?: Error) => void;
onCancel?: () => void;
onMutate?: (data: TData) => void;
}

Inside the PluginReturn<TData, TParams> type, it stores some lifecycle callback hooks which will be called at a certain phase of the request.

2. state

There’s also a state property of FetchState<TData, TParams> type. The type definition below shows it stores the context of the request. loading ,data, errors are the results we’d like to get from useRequest

export interface FetchState<TData, TParams extends any[]> {
loading: boolean;
params?: TParams;
data?: TData;
error?: Error;
}

So it could be used as this:

const { data, error, loading } = useRequest(service);

And the setState API is used to update the state.

Two main APIs of the Fetch class are runPluginHandler and runAsync , which are called by all of the other APIs to do some extra work.

3. runPluginHandler

runPluginHandler(event: keyof PluginReturn<TData, TParams>, ...rest: any[]) {
// @ts-ignore
const r = this.pluginImpls.map((i) => i[event]?.(...rest)).filter(Boolean);
return Object.assign({}, ...r);
}

This function accepts an event parameter which is of the union type onBefore | onRequest | onSuccess | onError | onFinally | onCancel | onMutate and other extra parameters. What this handler does is to call the relevant lifecycle hook from pluginImpls and return its result.

4. runAsync

async runAsync(...params: TParams): Promise<TData> {
this.count += 1;
const currentCount = this.count;
const {
stopNow = false,
returnNow = false,
...state
} = this.runPluginHandler('onBefore', params);
// stop request
if (stopNow) {
return new Promise(() => {});
}
this.setState({
loading: true,
params,
...state,
});
// return now
if (returnNow) {
return Promise.resolve(state.data);
}
this.options.onBefore?.(params); try {
// replace service
let { servicePromise } = this.runPluginHandler('onRequest', this.serviceRef.current, params);
if (!servicePromise) {
servicePromise = this.serviceRef.current(...params);
}
const res = await servicePromise; if (currentCount !== this.count) {
// prevent run.then when request is canceled
return new Promise(() => {});
}
// const formattedResult = this.options.formatResultRef.current ? this.options.formatResultRef.current(res) : res; this.setState({
data: res,
error: undefined,
loading: false,
});
this.options.onSuccess?.(res, params);
this.runPluginHandler('onSuccess', res, params);
this.options.onFinally?.(params, res, undefined); if (currentCount === this.count) {
this.runPluginHandler('onFinally', params, res, undefined);
}
return res;
} catch (error) {
if (currentCount !== this.count) {
// prevent run.then when request is canceled
return new Promise(() => {});
}
this.setState({
error,
loading: false,
});
this.options.onError?.(error, params);
this.runPluginHandler('onError', error, params);
this.options.onFinally?.(params, undefined, error); if (currentCount === this.count) {
this.runPluginHandler('onFinally', params, undefined, error);
}
throw error;
}
}

What this long function does is to implement callbacks that are passed in to give users the opportunity to process the result of the request instead of handling it automatically.

For example, In an onBefore hook, user can cancel a request before it’s been sent out ; In an onRequest hook, the function to fetch data can be overwritten, etc.

5. Other APIs

Other APIs such as runcancelrefresh will eventually call runPluginHandler
and runAsync .

The main responsibility of this Fetch class is to run callbacks in different phases of a request lifecycle and update the state.

Plugins

The implementation of useRequest separates the core logic and the complicity of each different functionality by the plugin mechanism. Fetch only care about when to call those plugin hooks and each plugin itself will only focus on customizing and doing its own logic.

Take usePollingPlugin as an example, the main logic of this plugin is to set a timeout in onFinally callback after each request using pollingInterval passed by users and run refresh function of the Fetch instance.

const usePollingPlugin: Plugin<any, any[]> = (
fetchInstance,
{ pollingInterval, pollingWhenHidden = true },
) => {
const timerRef = useRef<NodeJS.Timeout>();
const unsubscribeRef = useRef<() => void>();
const stopPolling = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
unsubscribeRef.current?.();
};
useUpdateEffect(() => {
if (!pollingInterval) {
stopPolling();
}
}, [pollingInterval]);
if (!pollingInterval) {
return {};
}
return {
onBefore: () => {
stopPolling();
},
onFinally: () => {
// if pollingWhenHidden = false && document is hidden, then stop polling and subscribe revisible
if (!pollingWhenHidden && !isDocumentVisible()) {
unsubscribeRef.current = subscribeReVisible(() => {
fetchInstance.refresh();
});
return;
}
timerRef.current = setTimeout(() => {
fetchInstance.refresh();
}, pollingInterval);
},
onCancel: () => {
stopPolling();
},
};
};

Adding up

To hook up the core Fetch class and plugins together to make this hook work, useRequestImplement is called and accepts request options and plugins from a higher level and Fetch will be instantiated inside the function.

function useRequestImplement<TData, TParams extends any[]>(
service: Service<TData, TParams>,
options: Options<TData, TParams> = {},
plugins: Plugin<TData, TParams>[] = [],
) {
const { manual = false, ...rest } = options;
const fetchOptions = {
manual,
...rest,
};
const serviceRef = useLatest(service); const update = useUpdate(); const fetchInstance = useCreation(() => {
const initState = plugins.map((p) => p?.onInit?.(fetchOptions)).filter(Boolean);
return new Fetch<TData, TParams>(
serviceRef,
fetchOptions,
update,
Object.assign({}, ...initState),
);
}, []);
fetchInstance.options = fetchOptions;
// run all plugins hooks
fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));
useMount(() => {
if (!manual) {
// useCachePlugin can set fetchInstance.state.params from cache when init
const params = fetchInstance.state.params || options.defaultParams || [];
// @ts-ignore
fetchInstance.run(...params);
}
});
useUnmount(() => {
fetchInstance.cancel();
});
return {
loading: fetchInstance.state.loading,
data: fetchInstance.state.data,
error: fetchInstance.state.error,
params: fetchInstance.state.params || [],
cancel: useMemoizedFn(fetchInstance.cancel.bind(fetchInstance)),
refresh: useMemoizedFn(fetchInstance.refresh.bind(fetchInstance)),
refreshAsync: useMemoizedFn(fetchInstance.refreshAsync.bind(fetchInstance)),
run: useMemoizedFn(fetchInstance.run.bind(fetchInstance)),
runAsync: useMemoizedFn(fetchInstance.runAsync.bind(fetchInstance)),
mutate: useMemoizedFn(fetchInstance.mutate.bind(fetchInstance)),
} as Result<TData, TParams>;
}
export default useRequestImplement;

Finally, this function will be returned in a useRequest function with custom plugins along with its native plugins passed in.

function useRequest<TData, TParams extends any[]>(
service: Service<TData, TParams>,
options?: Options<TData, TParams>,
plugins?: Plugin<TData, TParams>[],
) {
return useRequestImplement<TData, TParams>(service, options, [
...(plugins || []),
useDebouncePlugin,
useLoadingDelayPlugin,
usePollingPlugin,
useRefreshOnWindowFocusPlugin,
useThrottlePlugin,
useRefreshDeps,
useCachePlugin,
useRetryPlugin,
useReadyPlugin,
] as Plugin<TData, TParams>[]);
}

Summarise

The main idea of implementing a plugin is to find out the appropriate phase of the request lifecycle and plug in the core logic of the hook. The most important takeaway from the exploration of the hook’s source code is the approach of separating its core Fetch function and its plugins, which makes it more reusable and maintainable. Users are able to extend the plugins easily as they wish and each of the plugins works independently. I believe it’s a great example of the single responsibility principle and that’s something I could borrow from when customizing a hook or implementing complicated logic.

Reference

https://ahooks.js.org/hooks/use-request/basic
https://github.com/alibaba/hooks/tree/master/packages/hooks/src/useRequest/src
https://qdmana.com/2022/02/202202020201538966.html

--

--