background

In a project where the front and back ends are separated, we need to obtain data through an asynchronous request, which requires a lot of processing, such as:

  • Loading is displayed, indicating that data is being requested
  • Every asynchronous operation requires a try-catch to catch an error
  • When a request fails, you need to handle the error case

Everyone on the team needs to write repetitive code when dealing with interfaces. So this article takes a step-by-step approach to how to package a powerful hooks that address the above pain points and allow development to focus only on business logic.

Goals and benefits

UseRequest provides the following functions:

  • Manual request/automatic request
  • Conditional/dependent requests
  • Polling/shake-off/dependency requests
  • Page/load more

The following calls are expected:

Const {loading, run, data} = useRequest(() => {// here to write specific asynchronous request}, {// some configuration parameters});Copy the code

As you can see in the code above, useRequest can pass in two arguments, the first is a function type called requestFn for an asynchronous request, and the second is some extended argument called options, which returns an object

parameter instructions type
loading Whether the requestFn is running boolean
data The data returned by requestFn is undefined by default any
run Perform requestFn Function
error The exception thrown by requestFn is undefined by default undefined / Error

The second parameter is used for some extended functions, and the following parameters are agreed:

The title instructions type The default value
auto Initialize the automatic execution of the requestFn boolean false
onSuccess RequestFn resolve the trigger Function
onError RequestFn reject the trigger Function
cacheKey Once this value is set, caching is enabled string

Technical solution

In writing hooks, we extend hooks step by step to implement a powerful asynchronous request state management library

The basic package

The second optional argument is not considered, only if the requestFn is passed in

Implementation framework

function useRequest<D.P extends any[] > (requestFn: RequestFn<D, P>, options: BaseOptions<D, P> = {}) {

  const [loading, setLoading] = useState(false);

  const [data, setData] = useState<D>();

  const [err, setErr] = useState();

  const run = useCallback(async(... params: P) => { setLoading(true);

    let res;

    try {

      res = awaitrequestFn(... params); setData(res); }catch (error) {

      setErr(error);

    }

    setLoading(false);

    returnres; } []);return {

    loading,

    data,

    err,

    run,

  };

}
Copy the code

The above code implements basic asynchronous management

  • When the status of an asynchronous request changes, Loading can update in time
  • Err is also updated when a request throws an error
  • Can you decide when to execute an asynchronous function

The code above has been able to implement the most basic capabilities, and on top of that, the following extends the automatic execution capabilities

Again, use useEffect to listen for changes in auto

const { defaultParams } = options

useEffect(() = > {

    if(auto) { run(... (defaultParamsas P));

    }

  }, [auto]);
Copy the code

When auto is true, the asynchronous function is run directly. Once the above functionality is implemented, we can call it as follows:

function generateName() :Promise<string> {

  return new Promise(resolve= > {

    setTimeout(() = > {

      resolve('name');

    }, 5000);

  });

}

function Index() {

  const { run, data, loading, err } = useRequest(async() = >await generateName());

  useEffect(() = >{ run(); } []);console.log(data, loading);

  console.log(err);

  return (

    <div style={{ fontSize: 14}} >

      <p>data: {data}</p>

      <p>loading: {String(loading)}</p>

    </div>

  );

}
Copy the code

When run is called, the asynchronous interface is called and the values returned by useRequest are updated

The cache

How to cache requests this is a common interview question. We expect to implement such functionality

  • The result can be cached after the first request

  • It can be retrieved from the cache when requested again

  • And when reading the cache, it can automatically initiate a request to pull the latest resources to update the cache

First of all, we need to think about the design of the cache system, which needs to meet the following characteristics

  • Ability to add cache

  • Ability to cache expiration times

  • Delete the cache

Here we use Map to cache, Map is a group of key-value pair structure, with very fast search speed, with the help of GET, set API to complete the storage of data

class BaseCache {

  protected value: Map<CachedKeyType, T>

  constructor() {

    this.value = new Map<CachedKeyType, T>();

  }



  public getValue = (key: CachedKeyType): T= > {

    return this.value.get(key )

  }



  public setValue = (key: CachedKeyType, data: T): void= > {

    this.setValue(key, data)

  }



  public remove = (key: CachedKeyType) = > {

    this.value.delete(key)

  }

}
Copy the code

In addition to the expiration time logic, setCache will automatically delete the result in the cache after the expiration time is exceeded. The change is as follows:

const setCache = (key: CachedKeyType, cacheTime: number, data: any) = > {

  const currentCache = cache.getValue(key);

  if(currentCache? .timer) {clearTimeout(currentCache.timer);

  }



  let timer: Timer | undefined = undefined;



  if (cacheTime > -1) {

    // Data is not active in cacheTime, then deleted

    timer = setTimeout(() = > {



      cache.remove(key);



    }, cacheTime);

  }

  const value = {

    data,

    timer,

    startTime: new Date().getTime(),

  }

  cache.setValue(key, value);

};
Copy the code

In the above code, when setting the value, first determine whether the timer exists, if so, cancel the timer, release the memory. If the current cache duration is set, you need to add a timer to delete the current cache. Finally, there are two parts in the Cache

  • Data: information about the request
  • Timer: indicates the ID of a timer

If we want to implement fetch directly from the cache and automatically execute the request interface in the background, we need to cache two parts:

  1. Request the results
  2. Request parameters

So we need to construct a request object, which contains the following functions:

  1. Provides the ability to actively call asynchronous functions
  2. Save request results
  3. Save request parameters
  4. Provides a hook that can be triggered when a value changes

The specific code is as follows:

class Request<D.P extends any[] >{

  that: any = this;

  options: BaseOptions<D, P>;

  requestFn: BaseRequestFnType<D, P>;

  state: RequestsStateType<D, P> = {

    loading: false.run: this.run.bind(this.that),

    data: undefined.params: [] as any.changeData: this.changeData.bind(this.that),

  };



  constructor(requestFn: BaseRequestFnType<D, P>, options: BaseOptions<D, P>, changeState: (data: RequestsStateType<D, P>) => void) {

    this.options = options;

    this.requestFn = requestFn;

    this.changeState = this.changState;

  }



  async run(. params: P) {

    this.setState({

      loading: true,

      params,

    });

    let res;

    try {

      res = await this.requestFn(... params);this.setState({

        data: res,

        error: undefined.loading: false});if (this.options.onSuccess) {

        this.options.onSuccess(res);

      }

      return res;

    } catch (error) {

      this.setState({

        data: undefined,

        error,

        loading: false});if (this.options.onError) {

        this.options.onError(error);

      }

      returnerror; }}setState(s = {} as Partial<BaseReturnValue<D, P>>) {

    this.state = { ... this.state, ... s, };if (this.onChangeState) {

      this.onChangeState(this.state); }}changeData(data: D) {

    this.setState({ data, }); }}Copy the code

This object holds all the information related to the Request, so the useRequest needs to be changed before. All the state needs to be retrieved from the Request object, so change the useRequest implemented before. When we call the interface, we first try to fetch data from the cache, and if the cache exists, we return the data directly from the cache

const { cacheKey } = options

const cacheKeyRef = useRef(cacheKey)

cacheKeyRef.current = cacheKey

const [requests, setRequests] = useState<RequestsStateType<D, P> | null> (() = > {

  if (cacheKey && cacheKeyRef.current) {

    return getCache(cacheKeyRef.current);

  }

  return null;

});
Copy the code

The initial value is fetched directly from the cache via cacheKey, and if present, the contents of the cache are returned

We also need to set the cached values

  useUpdateEffect(() = > {

  if (cacheKeyRef.current) {

    setCache(cacheKeyRef.current, cacheTime, requests);

  }

}, [requests]);
Copy the code

If the requests values change, update the values in the cache. If the requests values change, we need to update the values in the cache as well as the run function provided externally. When we run, we need to distinguish whether the values exist in the cache. The changed code looks like this:

  const run = useCallback(async(... params: P) => {let currentRequest;

    if (cacheKeyRef.current) {

      currentRequest = getCache(cacheKeyRef.current);

    }



    if(! currentRequest) {const requestState = new Request(requestFn, options, onChangeState).state;

      setRequests(requestState);



      returnrequestState.run(... params); }returncurrentRequest.run(); } []);Copy the code

Finally, the result is returned. If there is cached content, we need to use cached data

if (requests) {

    return requests;

  } else {

    return {

      loading: auto,

      data: initData,

      error: undefined,

      run,

    };

  }
Copy the code

We tried calling the value of useRequest and found that requests was always the original value in the Request object. This is because we didn’t change the content of Requests in time when the asynchronous function was called, so we’re writing a function to change the content of Requests

  const onChangeState = useCallback((data: RequestsStateType<D, P>) = >{ setRequests(data); } []);Copy the code

Put the above code together and you’ll be able to cache the results. Let’s write a demo to see what happens

function generateName() :Promise<number> {

  return new Promise(resolve= > {

    setTimeout(() = > {

      resolve(Math.random());

    }, 1000);

  });

}

function Article() {

  const { data } = useRequest(generateName, {

    cacheKey: 'generateName'.auto: true});return <p>123{data}</p>;

}



function Index() {

  const { run, data, loading } = useRequest(generateName, {

    cacheKey: 'generateName'});const [bool, setBool] = useState(false);



  useEffect(() = >{ run(); } []);return (

    <div style={{ fontSize: 14}} >

      <p>data: {data}</p>

      <p>loading: {String(loading)}</p>

      <button onClick={()= >setBool(! bool)} type="button"> repeat</button>

      {bool && <Article />}

    </div>

  );

}
Copy the code

The Article and Index components both call the same interface, and the CacheKey is the same when the Article immediately gets the value of the first run

The first time it is run it is 0.92, then the Article interface calls it and gets a value of 0.92, which can be updated behind the scenes, and the next time the Article component is rendered it is updated to the latest value

To load more

On this basis we expect useRequest to be able to extend and load more functions, which need to meet the following functions

  • Click to load more
  • Drop down to load more

Because there is too much data in the actual service to completely cover, only the following conditions are covered:

Request body-pagesize: request per page tree - offset: return structure - list: array contents - total: return total number of entriesCopy the code

Let’s talk about the implementation

  const { initPageSize = 10, ref, threshold = 100. restOptions } = options;const [list, setList] = useState<LoadMoreItemType<D>[]>([]);

  const [loadingMore, setLoadingMore] = useState(false);

    constresult = useRequest(requestFn, { ... restOptions,onSuccess: res= > {

      setLoadingMore(false);

      console.log('onSuccess', res.list);

      setList(prev= > prev.concat(res.list));

      if(options.onSuccess) { options.onSuccess(res); }},onError: (. params) = > {

      setLoadingMore(false);

      if(options.onError) { options.onError(... params); }}});const { data, run, loading } = result;
Copy the code

The list variable is used to save the loaded variable, loadingMore indicates whether it is being loaded or not, before making an asynchronous request with the previously provided useRequest, it should be noted that onSuccess and onError need to be rewrapped to change the current data state

When ref is passed in, it means you need to slide to the bottom of a container and load more operations

useEffect(() = > {

    if(! ref || ! ref.current) {return noop;

    }

    const handleScroll = () = > {

      if(! ref || ! ref.current) {return;

      }



      if(ref.current.scrollHeight - ref.current.scrollTop <= ref.current.clientHeight + threshold) { loadMore(); }}; ref.current.addEventListener('scroll', handleScroll);

    return () = > {

      if (ref && ref.current) {

        ref.current.removeEventListener('scroll', handleScroll); }}; }, [ref && ref.current, loadMore]);Copy the code

The loadMore code basically passes the length of the current list as offset into the run provided by useRequest earlier

  const loadMore = useCallback(

    (customObj = {}) = > {

      console.log(noMore, loading, loadingMore, 'customObj');

      if (noMore || loading || loadingMore) {

        return;

      }

      setLoadingMore(true);

      run({

        current,

        pageSize: initPageSize,

        offset: list.length, ... customObj, }); }, [noMore, loading, loadingMore, current, run, data, list] );Copy the code

So you can load more, and when you get to the bottom you can load more automatic loading and you can do that, so you don’t have to write the example here

conclusion

Providing a packaged useRequest can save a lot of business code duplication. The overall implementation of the above is based on the open source hooks library of Ant. Some of the less common functions are not explained one by one