├── .gitignore ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config ├── env.js ├── jest │ ├── cssTransform.js │ └── fileTransform.js └── paths.js ├── icon.svg ├── jest.config.js ├── package.json ├── scripts └── test.js ├── src ├── ApiProvider.tsx ├── __tests__ │ ├── ApiProvider.test.tsx │ ├── __snapshots__ │ │ └── ApiProvider.test.tsx.snap │ ├── common.test.ts │ ├── customTypes.test.tsx │ ├── ssr.test.ts │ └── useApi.test.tsx ├── common.ts ├── context.tsx ├── index.ts ├── ssr.ts ├── typings.d.ts └── useApi.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .vscode 8 | 9 | # testing 10 | /coverage 11 | .coveralls.yml 12 | 13 | # production 14 | /build 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | demo.tsx 28 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - lts/* 4 | - 11 5 | - 13 6 | 7 | jobs: 8 | include: 9 | - stage: Coveralls 10 | node_js: lts/* 11 | script: 12 | - yarn coveralls 13 | # Define the release stage that runs semantic-release 14 | - stage: Release 15 | node_js: lts/* 16 | deploy: 17 | provider: script 18 | skip_cleanup: true 19 | script: 20 | - npx semantic-release 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.5.1](https://github.com/RyanRoll/react-use-api/compare/v2.5.0...v2.5.1) (2020-09-22) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **SSR:** show console error instead of throw errors for Maximum executing times while fetching axio ([ea839f4](https://github.com/RyanRoll/react-use-api/commit/ea839f4a8e6127bba912d51ada50c1a98efa4fcb)) 7 | 8 | # [2.5.0](https://github.com/RyanRoll/react-use-api/compare/v2.4.1...v2.5.0) (2020-09-14) 9 | 10 | 11 | ### Features 12 | 13 | * **useCache:** update readme and provide more details ([#19](https://github.com/RyanRoll/react-use-api/issues/19)) ([e1485ac](https://github.com/RyanRoll/react-use-api/commit/e1485ac71d3595f71b1dab8fb8dafb9115960bea)) 14 | 15 | ## [2.4.1](https://github.com/RyanRoll/react-use-api/compare/v2.4.0...v2.4.1) (2020-09-01) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * **pre-release:** add new option - useCache ([75e9222](https://github.com/RyanRoll/react-use-api/commit/75e92221e7e51c1b59dcd547f18a05a57db7a260)) 21 | 22 | # [2.4.0](https://github.com/RyanRoll/react-use-api/compare/v2.3.1...v2.4.0) (2020-08-06) 23 | 24 | 25 | ### Features 26 | 27 | * **TypeScript:** Custom data type support ([#17](https://github.com/RyanRoll/react-use-api/issues/17)) ([366a7de](https://github.com/RyanRoll/react-use-api/commit/366a7de95c94b1d46d46fda61d0caba0e06ccc0e)) 28 | 29 | ## [2.3.1](https://github.com/RyanRoll/react-use-api/compare/v2.3.0...v2.3.1) (2020-06-29) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * **hotfix:** make ssr work ([713a103](https://github.com/RyanRoll/react-use-api/commit/713a10375c6da9d9508e08e70e59b7f087c8509a)) 35 | 36 | # [2.3.0](https://github.com/RyanRoll/react-use-api/compare/v2.2.0...v2.3.0) (2020-06-29) 37 | 38 | 39 | ### Features 40 | 41 | * **options:** add a new option "skip" to avoid calling API ([8f25d60](https://github.com/RyanRoll/react-use-api/commit/8f25d6008f800c562e9239b84538f7698d80538d)) 42 | 43 | # [2.2.0](https://github.com/RyanRoll/react-use-api/compare/v2.1.2...v2.2.0) (2019-11-25) 44 | 45 | 46 | ### Features 47 | 48 | * **#7:** add an argument postProcess for injectSSRHtml ([48f78b4](https://github.com/RyanRoll/react-use-api/commit/48f78b4)), closes [#7](https://github.com/RyanRoll/react-use-api/issues/7) 49 | 50 | ## [2.1.2](https://github.com/RyanRoll/react-use-api/compare/v2.1.1...v2.1.2) (2019-11-07) 51 | 52 | 53 | ### Bug Fixes 54 | 55 | * **injectSSRHtml:** bug fix where the arguments of shouldUseApiCache were wrong ([d7d6d13](https://github.com/RyanRoll/react-use-api/commit/d7d6d13)) 56 | 57 | ## [2.1.1](https://github.com/RyanRoll/react-use-api/compare/v2.1.0...v2.1.1) (2019-11-07) 58 | 59 | 60 | ### Bug Fixes 61 | 62 | * **injectSSRHtml:** rule the uncached data out by shouldUseApiCache() ([6702a6c](https://github.com/RyanRoll/react-use-api/commit/6702a6c)) 63 | 64 | # [2.1.0](https://github.com/RyanRoll/react-use-api/compare/v2.0.0...v2.1.0) (2019-09-28) 65 | 66 | 67 | ### Features 68 | 69 | * **useApi:** add shouldUseApiCache to enable/disable the particular requests ([8ee5e0e](https://github.com/RyanRoll/react-use-api/commit/8ee5e0e)) 70 | 71 | # [2.0.0](https://github.com/RyanRoll/react-use-api/compare/v1.1.0...v2.0.0) (2019-09-23) 72 | 73 | 74 | ### Features 75 | 76 | * **useApi:** Bug fix and trigger CI again ([634b612](https://github.com/RyanRoll/react-use-api/commit/634b612)) 77 | 78 | 79 | ### BREAKING CHANGES 80 | 81 | * **useApi:** revert keepState=false for request() 82 | 83 | # [1.1.0](https://github.com/RyanRoll/react-use-api/compare/v1.0.8...v1.1.0) (2019-09-22) 84 | 85 | 86 | ### Features 87 | 88 | * **useApi:** add a new property `fromCache` for state data ([cacecf8](https://github.com/RyanRoll/react-use-api/commit/cacecf8)) 89 | 90 | ## [1.0.8](https://github.com/RyanRoll/react-use-api/compare/v1.0.7...v1.0.8) (2019-08-21) 91 | 92 | 93 | ### Bug Fixes 94 | 95 | * **npm:** try to publish a new npm version since 1.0.7 has been swallowed by npm ([476d2c9](https://github.com/RyanRoll/react-use-api/commit/476d2c9)) 96 | 97 | ## [1.0.7](https://github.com/RyanRoll/react-use-api/compare/v1.0.6...v1.0.7) (2019-08-20) 98 | 99 | 100 | ### Bug Fixes 101 | 102 | * **shouldRequest:** A bug fix, the option shouldRequest did not work properly due to the previous fi ([8b5aa6a](https://github.com/RyanRoll/react-use-api/commit/8b5aa6a)) 103 | 104 | ## [1.0.6](https://github.com/RyanRoll/react-use-api/compare/v1.0.5...v1.0.6) (2019-08-18) 105 | 106 | 107 | ### Bug Fixes 108 | 109 | * **Unit Test:** add more info logs for debugging and adjust the associated test codes ([9fb6504](https://github.com/RyanRoll/react-use-api/commit/9fb6504)) 110 | 111 | ## [1.0.5](https://github.com/RyanRoll/react-use-api/compare/v1.0.4...v1.0.5) (2019-08-18) 112 | 113 | 114 | ### Bug Fixes 115 | 116 | * **readme:** update readme and code fix ([fb6ff87](https://github.com/RyanRoll/react-use-api/commit/fb6ff87)) 117 | 118 | ## [1.0.4](https://github.com/RyanRoll/react-use-api/compare/v1.0.3...v1.0.4) (2019-08-17) 119 | 120 | 121 | ### Bug Fixes 122 | 123 | * **readme:** amend readme and add threshold for coverage ([008f3b0](https://github.com/RyanRoll/react-use-api/commit/008f3b0)) 124 | 125 | ## [1.0.3](https://github.com/RyanRoll/react-use-api/compare/v1.0.2...v1.0.3) (2019-08-17) 126 | 127 | 128 | ### Bug Fixes 129 | 130 | * update readme ([e77eaf3](https://github.com/RyanRoll/react-use-api/commit/e77eaf3)) 131 | 132 | ## [1.0.2](https://github.com/RyanRoll/react-use-api/compare/v1.0.1...v1.0.2) (2019-08-17) 133 | 134 | 135 | ### Bug Fixes 136 | 137 | * **semantic-release:** Add two plugins of semantic-release ([5f1f54d](https://github.com/RyanRoll/react-use-api/commit/5f1f54d)) 138 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2019-Present Ryan Roll. https://github.com/RyanRoll/react-use-api. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 |
5 | React useApi() 6 |
7 |
8 |

9 |
10 | 11 | [![npm](https://img.shields.io/npm/v/react-use-api?style=for-the-badge&label=version&color=e56565)](https://www.npmjs.com/package/react-use-api) 12 | [![Build Status](https://img.shields.io/travis/ryanroll/react-use-api/master?style=for-the-badge)](https://travis-ci.org/RyanRoll/react-use-api) 13 | [![Coverage Status](https://img.shields.io/coveralls/github/RyanRoll/react-use-api/master?style=for-the-badge)](https://coveralls.io/github/RyanRoll/react-use-api?branch=master) 14 | ![npm type definitions](https://img.shields.io/npm/types/react-use-api?style=for-the-badge&color=0277BD) 15 | ![GitHub](https://img.shields.io/github/license/RyanRoll/react-use-api?style=for-the-badge&color=5C6BC0) 16 | 17 | [Axios](https://github.com/axios/axios)-based React hooks for async HTTP request data. `react-use-api` feeds API data to React components when SSR (Server-Side Rendering), and caches the data to Front-End. `react-use-api` makes your code consistent between the two sides and also supports diverse UI states for your application. 18 | 19 | > TypeScript Support 20 | 21 | > Thread-safe SSR 22 | 23 | ## Installation 24 | 25 | ❗*Axios is a peer dependency (prerequisite) and it has to be installed* 26 | 27 | ### NPM 28 | 29 | ```bash 30 | // NPM 31 | $ npm i react-use-api axios 32 | // or yarn 33 | $ yarn add react-use-api axios 34 | ``` 35 | 36 | ## Usage 37 | 38 | ### Setup With ApiProvider 39 | 40 | ```tsx 41 | import React from 'react' 42 | import ReactDom from 'react-dom' 43 | import useApi, { ApiProvider } from 'react-use-api' 44 | 45 | import App from './App' 46 | 47 | // This is optional from client side 48 | const apiContext = { 49 | settings: { 50 | cache: new LRU(), 51 | axios: axios as AxiosStatic | AxiosInstance, 52 | maxRequests: 50, // max requests count when running ReactDom.renderToString in SSR 53 | useCacheData: true, // whether to use the cached api data come from server 54 | alwaysUseCache: false, // whether to use the cached api data always for each api call 55 | clearLastCacheWhenConfigChanges: true, // clear the last cache data with the last config when the config changes 56 | debug: false, 57 | clientCacheVar: '__USE_API_CACHE__', // the property name of window to save the cached api data come from server side 58 | isSSR: (...args: any[]): boolean | void => typeof window === 'undefined', 59 | renderSSR: (...args: any[]): string => '', // a callback to render SSR HTML string 60 | shouldUseApiCache: ( 61 | config?: ReactUseApi.Config, 62 | cacheKey?: string 63 | ): boolean | void => true, // a callback to decide whether to use the cached api data 64 | }, 65 | } 66 | ReactDom.render( 67 | 68 | 69 | , 70 | document.getElementById('root') 71 | ) 72 | ``` 73 | 74 | ### React Hooks 75 | 76 | ```tsx 77 | import React from 'react' 78 | import useApi from 'react-use-api' 79 | 80 | export const Main = () => { 81 | const [data, { loading, error }, request] = useApi({ 82 | url: '/api/foo/bar', 83 | }) 84 | 85 | return ( 86 | <> 87 | {loading &&
Loading...
} 88 | {error &&
{error.response.data.errMsg}
} 89 | {data && ( 90 | <> 91 |
Hello! {data.username}
92 | 93 | 94 | )} 95 | 96 | ) 97 | } 98 | ``` 99 | 100 | ## Parameters 101 | 102 | ### Types 103 | 104 | ```ts 105 | const [data, state, request] = useApi( 106 | config: string | ReactUseApi.SingleConfig | ReactUseApi.MultiConfigs, 107 | options?: ReactUseApi.Options | ReactUseApi.Options['handleData'] 108 | ) 109 | ``` 110 | 111 | ### Code 112 | 113 | ```tsx 114 | const [data, state, request] = useApi(config, options) 115 | 116 | // request the API data again, omit options.useCache=true 117 | request(config?: ReactUseApi.Config, keepState = false) 118 | ``` 119 | 120 | With a custom TypeScript data type 121 | 122 | ```tsx 123 | interface IMyData { 124 | foo: string 125 | bar: string 126 | } 127 | const [data, state, request] = useApi(config, options) 128 | ``` 129 | 130 | ## Advanced Usages 131 | 132 | ### Fetching API data forcibly 133 | 134 | `useApi` calls API once only and retains data and state regardless of each time component rerendering unless the config changes. This act is same as invoking `request()`. 135 | 136 | ```tsx 137 | import React, { useState } from 'react' 138 | import useApi from 'react-use-api' 139 | 140 | export const Page = () => { 141 | const [page, setPage] = useState(1) 142 | const [data, { loading }, request] = useApi({ 143 | url: '/api/foo/bar', 144 | params: { 145 | page, 146 | }, 147 | }) 148 | return ( 149 | <> 150 | {data?.title ?? 'Hello'} 151 | 152 | 153 | ) 154 | } 155 | ``` 156 | 157 | ### Cache mechanism 158 | 159 | If your application works with SSR, `useApi` will use cached API data instead of calling API when rendering your application on client side for the first time. 160 | 161 | On the other hand, `options.useCache` allows you to tag the API to be saved in cache and share it with the components using the same API. 162 | 163 | ```tsx 164 | import React from 'react' 165 | import useApi from 'react-use-api' 166 | 167 | const Card = (useCache) => { 168 | const [data, , request] = useApi('/api/foo/bar', { useCache }) 169 | return ( 170 | <> 171 | <>{data?.name} 172 | // call api, never use cache data 173 | 174 | ) 175 | } 176 | 177 | // without cache and SSR 178 | const Page = () => { 179 | return ( 180 | <> 181 | // call api 182 | // call api 183 | 184 | ) 185 | } 186 | 187 | // with useCache 188 | const Page = () => { 189 | return ( 190 | <> 191 | // call api 192 | // use cache data 193 | // use cache data as well if cache exists 194 | // call api and never use cache data 195 | 196 | ) 197 | } 198 | ``` 199 | 200 | > `options.useCache=false` means never using cache data 201 | 202 | ### Decision to use cache data globally 203 | 204 | ```tsx 205 | import React from 'react' 206 | import ReactDOM from 'react-dom' 207 | import { ApiProvider, loadApiCache } from 'react-use-api' 208 | 209 | import App from './components/App' 210 | 211 | loadApiCache() 212 | 213 | const apiContext = { 214 | settings: { 215 | shouldUseApiCache(config, cacheKey) { 216 | // return false to deny 217 | if (config.url == '/api/v1/doNotUseCache') { 218 | return false 219 | } 220 | return true // default to return true 221 | }, 222 | alwaysUseCache: true, // set true to make ptions.useCache=true 223 | }, 224 | } 225 | 226 | ReactDOM.render( 227 | 228 | 229 | , 230 | document.getElementById('root') 231 | ) 232 | ``` 233 | 234 | ### Manually cache data clearing 235 | 236 | ```tsx 237 | import React, { useContext } from 'react' 238 | import { useApi, ApiContext } from 'react-use-api' 239 | 240 | const App = (props) => { 241 | const apiContext = useContext(ApiContext) 242 | const { settings, clearCache } = apiContext 243 | clearCache() 244 | } 245 | ``` 246 | 247 | ### Skipping useApi 248 | 249 | ```tsx 250 | import React from 'react' 251 | import useApi from 'react-use-api' 252 | 253 | const Page = (props) => { 254 | const [data] = useApi('/api/foo/bar', { skip: true }) // never call this API 255 | return <>{!data && 'No data'} 256 | } 257 | ``` 258 | 259 | ### Pagination or infinite scrolling 260 | 261 | ```tsx 262 | import React, { useState, useMemo, useCallback } from 'react' 263 | import useApi from 'react-use-api' 264 | 265 | export const Main = () => { 266 | const [offset, setOffset] = useState(0) 267 | const limit = 10 268 | const options = useMemo( 269 | () => ({ 270 | handleData, 271 | dependencies: { 272 | limit, 273 | }, 274 | }), 275 | [limit] 276 | ) 277 | // hasMore is a custom state here 278 | const [data, { loading, error, hasMore = true }, request] = useApi( 279 | getAPiList(), 280 | options 281 | ) 282 | const loadMore = useCallback(() => { 283 | const nextOffset = offset + limit 284 | // fetch the data and keep the current state and prevData 285 | request(getAPiList(nextOffset, limit), true) 286 | setOffset(nextOffset) 287 | }, [offset]) 288 | 289 | return ( 290 | <> 291 | {loading &&
Loading...
} 292 | {error &&
{error.response.data.errMsg}
} 293 | {data && ( 294 | <> 295 | {data.list.map(({ name }) => ( 296 |
{name}
297 | ))} 298 | {hasMore && } 299 | 300 | )} 301 | 302 | ) 303 | } 304 | 305 | export const getAPiList = (offset, limit) => ({ 306 | url: '/api/list', 307 | params: { 308 | offset, 309 | limit, 310 | }, 311 | }) 312 | 313 | // [IMPORTANT] Using any state setter in handleData is not allowed, 314 | // it will cause the component re-rendering infinitely while SSR rendering. 315 | export const handleData = (state) => { 316 | const { prevData = [], response, dependencies, error } = state 317 | if (!error) { 318 | const { 319 | data: { userList }, 320 | } = response 321 | const { limit } = dependencies 322 | if (userList.length < limit) { 323 | // update hasMore 324 | state.hasMore = false 325 | } 326 | return [...prevData, ...userList] 327 | } else { 328 | // show an error message from the api 329 | console.log(error.response.data.msg) 330 | } 331 | } 332 | ``` 333 | 334 | ## Config 335 | 336 | The config can be an [Axios Request Config](https://github.com/axios/axios#request-config) or a URL string. 337 | 338 | ```tsx 339 | const [data, state] = useApi('/api/foo/bar') 340 | // equals to 341 | const [data, state] = useApi({ 342 | url: '/api/foo/bar', 343 | }) 344 | ``` 345 | 346 | ### Options [Optional] 347 | 348 | | Name | Type | default | Description | 349 | | ------------- | --------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 350 | | handleData | Function(data: any, state: ReactUseApi.State) | | A callback function to deal with the data of the API's response. **IMPORTANT** Using any state setter in handleData is dangerous, which will cause the component re-rendering infinitely while SSR rendering. | 351 | | dependencies | Object | | The additional needed data using in handleData. `NOTE`: "dependencies" is supposed to immutable due to React's rendering policy. | 352 | | shouldRequest | Function | () => false | A callback to decide whether useApi re-fetches the API when `only re-rendering`. Returning true will trigger useApi to re-fetch. This option is helpful if you want to re-request an API when a route change occurs. | 353 | | watch | any[] | [] | An array of values that the effect depends on, this is the same as the second argument of useEffect. | 354 | | skip | Boolean | false | Sets true to skip API call. | 355 | | useCache | Boolean | -- | Sets true to use cached API data if cache exists (calling API and then saves it if there is no cache). Sets false to call API always. By default, `useApi` uses the cached data provided from SSR. | 356 | 357 | ## State 358 | 359 | ### First State (before calling API) 360 | 361 | The first state has only one property `loading` before calling API. 362 | 363 | | Name | Type | Default | Description | 364 | | --------- | ------- | ------- | ------------------------------------------------- | 365 | | loading | boolean | false | To indicate whether calling api or not. | 366 | | fromCache | boolean | false | To tell whether the data come from SSR API cache. | 367 | 368 | #### Full State (after calling API) 369 | 370 | The is the full state data structure after the api has responded. 371 | 372 | | Name | Type | Default | Description | 373 | | ------------- | ----------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 374 | | loading | boolean | false | To indicate whether calling api or not. | 375 | | fromCache | boolean | false | To tell whether the data come from SSR API cache. | 376 | | data | any | undefined | The processed data provided from `options.handleData`. | 377 | | response | AxiosResponse | undefined | The Axios' response. | 378 | | error | AxiosError | undefined | The Axios' error. | 379 | | dependencies | Object | undefined | The additional needed data using in handleData. `NOTE`: "dependencies" is supposed to immutable due to React's rendering policy. | 380 | | prevData | any | undefined | The previous data of the previous state. | 381 | | prevState | ReactUseApi.State | undefined | The previous state. | 382 | | [custom data] | any | | You can add your own custom state data into the state by setting up in `options.handleData`. For example, `state.foo = 'bar'`. The data always be preserved whether re-rendering. | 383 | 384 | #### Request Function 385 | 386 | A function allows requesting API data again. This function will trigger re-render. 387 | 388 | | Name | Type | Default | Description | 389 | | ---------- | ------------------ | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | 390 | | config | ReactUseApi.Config | The config passed from useApi() | An axios' config object to fetch API data. | 391 | | keepState | boolean | false | Set to true to maintain current state data, which facilitates combining previous data with current data, such as table list data. | 392 | | revalidate | boolean | false | Set to true to fetch API without using cache data. | 393 | 394 | ### Server Side Rendering (SSR) 395 | 396 | The biggest advantage of `react-use-api` is to make your code consistent for both client and server sides. Unlike using Next.js or Redux, these require you to fetch API data by yourself before feeding it to the React component. `react-use-api` does the chores for you and saves all your API data as cache for client side. 397 | 398 | `react-use-api` guarantees that the SSR for each HTTP request is thread-safe as long as passing the api context with SSR settings to `ApiProvider`. 399 | 400 | Please be aware that no lifecycle methods will be invoked when SSR. 401 | 402 | #### SSR and injecting cached api data 403 | 404 | ```tsx 405 | // server/render.js (based on Express framework) 406 | import React from 'react' 407 | import ReactDomServer from 'react-dom/server'; 408 | import { StaticRouter } from 'react-router-dom' 409 | import { ApiProvider, injectSSRHtml } from 'react-use-api' 410 | 411 | import App from '../../src/App' 412 | 413 | export const render = async (req, axios) => { 414 | const { url } = req 415 | const apiContext = { 416 | // configure your global options or SSR settings 417 | settings: { 418 | axios, // to set your custom axios instance 419 | isSSR: () => true, // we are 100% sure here is SSR mode 420 | }, 421 | } 422 | const routerContext = {} 423 | const renderSSR = () => 424 | ReactDomServer.renderToString( 425 | 426 | 427 | 428 | 429 | 430 | ) 431 | const html = await injectSSRHtml(apiContext, renderSSR) 432 | return html 433 | } 434 | ``` 435 | 436 | **The cache data has been inserted into your SSR HTML string as well.** 437 | 438 | > The cache data will be cleaned up after calling loadApiCache() by default 439 | 440 | ```html 441 | 444 | ``` 445 | 446 | #### SSR Settings of apiContext (ReactUseApi.CustomSettings) 447 | 448 | _Each property is optional_ 449 | 450 | | Name | Type | Default | Description | 451 | | ------------------------------- | ----------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | 452 | | cache | LRU | new LRU() | The cache instance based on lru-cache | 453 | | axios | AxiosStatic \| AxiosInstance | axios | axios instance (http client) | 454 | | maxRequests | number | 100 | The maximum of API requests when SSR | 455 | | useCacheData | boolean | true | Set true to inject JS cache data into html when calling `injectSSRHtml()` | 456 | | alwaysUseCache | boolean | false | Set true to use cache data always (equivalent to `options.useCache = true`) | 457 | | clearLastCacheWhenConfigChanges | boolean | true | This is default behavior that the cached data will be removed once the url config of useApi has been changed. Set false to remain the cached data | 458 | | debug | boolean | true | Set true to get debug message from console | 459 | | clientCacheVar | string | 'USE_API_CACHE' | The JS variable name of cache data | 460 | | renderSSR | Function | () => '' | A callback to render SSR string for injectSSRHtml() | 461 | | isSSR | Function | () => typeof window === 'undefined' | A function to determine if the current environment is server | 462 | | shouldUseApiCache | Function | (config?: ReactUseApi.Config, cacheKey?: string): boolean | Returns true to enable useApi to get the API data from API cache, which is loaded by `loadApiCache`. Default is true | 463 | 464 | #### Arguments of injectSSRHtml 465 | 466 | ```tsx 467 | injectSSRHtml( 468 | context: ReactUseApi.CustomContext, 469 | renderSSR?: () => string, 470 | postProcess?: (ssrHtml: string, apiCacheScript: string) => string, // a callback for after rendering SSR HTML string 471 | ): string 472 | 473 | ``` 474 | 475 | #### SSR - Load cached API data 476 | 477 | Please don't forget to invoke `loadApiCache()` to load the cached API data come from your server side. useApi will use the cache data instead of calling API when rendering your application for the first time. 478 | 479 | ```tsx 480 | // src/index.jsx 481 | import React from 'react' 482 | import ReactDOM from 'react-dom' 483 | import { ApiProvider, loadApiCache } from 'react-use-api' 484 | 485 | import App from './components/App' 486 | 487 | const rootElement = document.getElementById('root') 488 | const method = rootElement.hasChildNodes() ? 'hydrate' : 'render' 489 | 490 | loadApiCache() 491 | 492 | ReactDOM[method]( 493 | 494 | 495 | , 496 | rootElement 497 | ) 498 | ``` 499 | 500 | ## TypeScript Support 501 | 502 | All the associated types are provided by the namespace [ReactUseApi](src/typings.d.ts) as long as importing `react-use-api`. 503 | 504 | > NOTE, this only works if you set up compilerOptions.typeRoots: ["node_modules/@types"] in your tsconfig.json. 505 | 506 | > Support TypeScript v2.9+ only 507 | 508 | ## License 509 | 510 | MIT 511 | 512 | Icons made by [Eucalyp](https://www.flaticon.com/authors/eucalyp) from [www.flaticon.com](https://www.flaticon.com) is licensed by [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0). 513 | -------------------------------------------------------------------------------- /config/env.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const paths = require('./paths'); 6 | 7 | // Make sure that including paths.js after env.js will read .env variables. 8 | delete require.cache[require.resolve('./paths')]; 9 | 10 | const NODE_ENV = process.env.NODE_ENV; 11 | if (!NODE_ENV) { 12 | throw new Error( 13 | 'The NODE_ENV environment variable is required but was not specified.' 14 | ); 15 | } 16 | 17 | // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use 18 | var dotenvFiles = [ 19 | `${paths.dotenv}.${NODE_ENV}.local`, 20 | `${paths.dotenv}.${NODE_ENV}`, 21 | // Don't include `.env.local` for `test` environment 22 | // since normally you expect tests to produce the same 23 | // results for everyone 24 | NODE_ENV !== 'test' && `${paths.dotenv}.local`, 25 | paths.dotenv, 26 | ].filter(Boolean); 27 | 28 | // Load environment variables from .env* files. Suppress warnings using silent 29 | // if this file is missing. dotenv will never modify any environment variables 30 | // that have already been set. Variable expansion is supported in .env files. 31 | // https://github.com/motdotla/dotenv 32 | // https://github.com/motdotla/dotenv-expand 33 | dotenvFiles.forEach(dotenvFile => { 34 | if (fs.existsSync(dotenvFile)) { 35 | require('dotenv-expand')( 36 | require('dotenv').config({ 37 | path: dotenvFile, 38 | }) 39 | ); 40 | } 41 | }); 42 | 43 | // We support resolving modules according to `NODE_PATH`. 44 | // This lets you use absolute paths in imports inside large monorepos: 45 | // https://github.com/facebook/create-react-app/issues/253. 46 | // It works similar to `NODE_PATH` in Node itself: 47 | // https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders 48 | // Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. 49 | // Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. 50 | // https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 51 | // We also resolve them to make sure all tools using them work consistently. 52 | const appDirectory = fs.realpathSync(process.cwd()); 53 | process.env.NODE_PATH = (process.env.NODE_PATH || '') 54 | .split(path.delimiter) 55 | .filter(folder => folder && !path.isAbsolute(folder)) 56 | .map(folder => path.resolve(appDirectory, folder)) 57 | .join(path.delimiter); 58 | 59 | // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be 60 | // injected into the application via DefinePlugin in Webpack configuration. 61 | const REACT_APP = /^REACT_APP_/i; 62 | 63 | function getClientEnvironment(publicUrl) { 64 | const raw = Object.keys(process.env) 65 | .filter(key => REACT_APP.test(key)) 66 | .reduce( 67 | (env, key) => { 68 | env[key] = process.env[key]; 69 | return env; 70 | }, 71 | { 72 | // Useful for determining whether we’re running in production mode. 73 | // Most importantly, it switches React into the correct mode. 74 | NODE_ENV: process.env.NODE_ENV || 'development', 75 | // Useful for resolving the correct path to static assets in `public`. 76 | // For example, . 77 | // This should only be used as an escape hatch. Normally you would put 78 | // images into the `src` and `import` them in code to get their paths. 79 | PUBLIC_URL: publicUrl, 80 | } 81 | ); 82 | // Stringify all values so we can feed into Webpack DefinePlugin 83 | const stringified = { 84 | 'process.env': Object.keys(raw).reduce((env, key) => { 85 | env[key] = JSON.stringify(raw[key]); 86 | return env; 87 | }, {}), 88 | }; 89 | 90 | return { raw, stringified }; 91 | } 92 | 93 | module.exports = getClientEnvironment; 94 | -------------------------------------------------------------------------------- /config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This is a custom Jest transformer turning style imports into empty objects. 4 | // http://facebook.github.io/jest/docs/en/webpack.html 5 | 6 | module.exports = { 7 | process() { 8 | return 'module.exports = {};'; 9 | }, 10 | getCacheKey() { 11 | // The output is always the same. 12 | return 'cssTransform'; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /config/jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const camelcase = require('camelcase'); 5 | 6 | // This is a custom Jest transformer turning file imports into filenames. 7 | // http://facebook.github.io/jest/docs/en/webpack.html 8 | 9 | module.exports = { 10 | process(src, filename) { 11 | const assetFilename = JSON.stringify(path.basename(filename)); 12 | 13 | if (filename.match(/\.svg$/)) { 14 | // Based on how SVGR generates a component name: 15 | // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 16 | const pascalCaseFileName = camelcase(path.parse(filename).name, { 17 | pascalCase: true, 18 | }); 19 | const componentName = `Svg${pascalCaseFileName}`; 20 | return `const React = require('react'); 21 | module.exports = { 22 | __esModule: true, 23 | default: ${assetFilename}, 24 | ReactComponent: React.forwardRef(function ${componentName}(props, ref) { 25 | return { 26 | $$typeof: Symbol.for('react.element'), 27 | type: 'svg', 28 | ref: ref, 29 | key: null, 30 | props: Object.assign({}, props, { 31 | children: ${assetFilename} 32 | }) 33 | }; 34 | }), 35 | };`; 36 | } 37 | 38 | return `module.exports = ${assetFilename};`; 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /config/paths.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const url = require('url'); 6 | 7 | // Make sure any symlinks in the project folder are resolved: 8 | // https://github.com/facebook/create-react-app/issues/637 9 | const appDirectory = fs.realpathSync(process.cwd()); 10 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath); 11 | 12 | const envPublicUrl = process.env.PUBLIC_URL; 13 | 14 | function ensureSlash(inputPath, needsSlash) { 15 | const hasSlash = inputPath.endsWith('/'); 16 | if (hasSlash && !needsSlash) { 17 | return inputPath.substr(0, inputPath.length - 1); 18 | } else if (!hasSlash && needsSlash) { 19 | return `${inputPath}/`; 20 | } else { 21 | return inputPath; 22 | } 23 | } 24 | 25 | const getPublicUrl = appPackageJson => 26 | envPublicUrl || require(appPackageJson).homepage; 27 | 28 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer 29 | // "public path" at which the app is served. 30 | // Webpack needs to know it to put the right ` 252 | ) 253 | }) 254 | 255 | it('should injectSSRHtml work well without the html of the api cache script', async () => { 256 | const renderSSR = jest.fn().mockReturnValue(html) 257 | const context = configure({ 258 | settings: { 259 | ...copySettings(), 260 | renderSSR, 261 | useCacheData: false, 262 | }, 263 | }) 264 | const { settings } = context 265 | const { cache } = settings 266 | cache.reset = jest.fn() 267 | cache.dump = jest.fn() 268 | expect.hasAssertions() 269 | const ssrHtml = await injectSSRHtml(context) 270 | expect(renderSSR).toHaveBeenCalled() 271 | expect(cache.reset).toHaveBeenCalled() 272 | expect(feedRequests).toHaveBeenLastCalledWith(context, html) 273 | expect(ssrHtml).toEqual(html) 274 | }) 275 | 276 | it('should injectSSRHtml work well with postProcess', async () => { 277 | const renderSSR = jest.fn().mockReturnValue(html) 278 | const context = configure({ 279 | settings: { 280 | ...copySettings(), 281 | }, 282 | }) 283 | const { settings } = context 284 | const { cache } = settings 285 | cache.reset = jest.fn() 286 | cache.set(JSON.stringify({ url: 'foo' }), 'bar') 287 | expect.hasAssertions() 288 | let originSSRHtml = '' 289 | const postProcess = jest.fn((ssrHtml: string, apiCacheScript: string) => { 290 | originSSRHtml = ssrHtml + apiCacheScript 291 | return '404' 292 | }) 293 | const ssrHtml = await injectSSRHtml(context, renderSSR, postProcess) 294 | expect(renderSSR).toHaveBeenCalled() 295 | expect(cache.reset).toHaveBeenCalled() 296 | expect(postProcess).toHaveBeenCalled() 297 | expect(feedRequests).toHaveBeenLastCalledWith(context, html) 298 | expect(originSSRHtml).toEqual( 299 | `${html}` 300 | ) 301 | expect(ssrHtml).toEqual('404') 302 | }) 303 | 304 | it('should rule the uncached data out by shouldUseApiCache()', async () => { 305 | const renderSSR = jest.fn().mockReturnValue(html) 306 | const context = configure({ 307 | settings: { 308 | ...copySettings(), 309 | renderSSR, 310 | shouldUseApiCache(config: ReactUseApi.SingleConfig, cacheKey) { 311 | if ( 312 | cacheKey.includes('/no/cache') || 313 | config.url.includes('/nodata') 314 | ) { 315 | return false 316 | } 317 | }, 318 | }, 319 | }) 320 | const { settings } = context 321 | const { cache } = settings 322 | cache.reset = jest.fn() 323 | cache.set(JSON.stringify({ url: '/no/cache' }), 'no cache') 324 | cache.set(JSON.stringify({ url: '/nodata' }), 'no data') 325 | cache.set(JSON.stringify({ url: 'foo' }), 'bar') 326 | expect.hasAssertions() 327 | const ssrHtml = await injectSSRHtml(context) 328 | expect(renderSSR).toHaveBeenCalled() 329 | expect(ssrHtml).toEqual( 330 | `${html}` 331 | ) 332 | }) 333 | }) 334 | 335 | describe('loadApiCache tests', () => { 336 | it('should work well with cache data', () => { 337 | const { clientCacheVar, cache } = defaultSettings 338 | Object.assign(window, { 339 | [clientCacheVar]: cacheData, 340 | }) 341 | loadApiCache() 342 | expect(cache.dump()).toEqual(cacheData) 343 | expect(window.hasOwnProperty(clientCacheVar)).toBe(false) 344 | }) 345 | 346 | it('should nothing happen if there is no cache data', () => { 347 | const context = configure({ 348 | settings: { 349 | ...copySettings(), 350 | }, 351 | }) 352 | const { 353 | settings: { clientCacheVar, cache }, 354 | } = context 355 | delete window[clientCacheVar] 356 | loadApiCache(context) 357 | expect(cache.length).toBe(0) 358 | }) 359 | }) 360 | 361 | // TODO: an integration test 362 | -------------------------------------------------------------------------------- /src/__tests__/useApi.test.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import React, { useState } from 'react' 4 | import LRU from 'lru-cache' 5 | import { renderHook, act } from '@testing-library/react-hooks' 6 | import MockAdapter from 'axios-mock-adapter' 7 | import axios from 'axios' 8 | 9 | import { ApiProvider } from '../ApiProvider' 10 | import { useApi, reducer, fetchApi, handleUseApiOptions } from '../useApi' 11 | import { ACTIONS, initState } from '../common' 12 | 13 | const mock = new MockAdapter(axios) 14 | const originalLog = console.log 15 | 16 | beforeEach(() => { 17 | jest.resetModules() 18 | mock.reset() 19 | }) 20 | 21 | afterEach(() => { 22 | console.log = originalLog 23 | }) 24 | 25 | describe('useApi tests', () => { 26 | const url = '/api/v1/foo/bar' 27 | const apiData = { 28 | foo: { 29 | bar: true, 30 | }, 31 | } 32 | const listUrl = '/api/v1/list' 33 | const listData = [ 34 | { 35 | foo: 'bar', 36 | }, 37 | { 38 | abc: 123, 39 | }, 40 | ] 41 | const errorUrl = '/api/v1/500' 42 | const errorData = { 43 | msg: '500 Server Error', 44 | } 45 | beforeEach(() => { 46 | mock.onGet(url).reply(200, apiData) 47 | mock.onGet(listUrl).reply(200, listData) 48 | mock.onGet(errorUrl).reply(500, errorData) 49 | }) 50 | const createWrapper = (context: ReactUseApi.CustomContext) => ({ 51 | children, 52 | }) => {children} 53 | 54 | it('should work well without options', async () => { 55 | const context = { 56 | settings: { 57 | isSSR: () => false, 58 | }, 59 | } as ReactUseApi.CustomContext 60 | const wrapper = createWrapper(context) 61 | const { result, waitForNextUpdate, rerender } = renderHook( 62 | () => 63 | useApi({ 64 | url, 65 | }), 66 | { wrapper } 67 | ) 68 | const [data, state, request] = result.current 69 | expect(data).toBeUndefined() 70 | expect(state).toEqual({ 71 | loading: true, 72 | fromCache: false, 73 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 74 | error: undefined, 75 | }) 76 | expect(request).toBeTruthy() 77 | 78 | await waitForNextUpdate() 79 | 80 | const [uData, uState] = result.current 81 | expect(uData).toEqual(apiData) 82 | expect(uState).toEqual({ 83 | loading: false, 84 | fromCache: false, 85 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 86 | error: undefined, 87 | prevData: undefined, 88 | prevState: { 89 | loading: true, 90 | fromCache: false, 91 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 92 | error: undefined, 93 | }, 94 | response: { status: 200, data: apiData, headers: undefined }, 95 | dependencies: undefined, 96 | data: apiData, 97 | }) 98 | const { 99 | collection: { ssrConfigs }, 100 | } = context 101 | expect(ssrConfigs.length).toBe(0) 102 | 103 | rerender() 104 | 105 | // should be same after rerender 106 | const [nData, nState] = result.current 107 | expect(nData).toBe(uData) 108 | expect(nState).toBe(uState) 109 | }) 110 | 111 | it('should work well by string type config without options', async () => { 112 | const wrapper = createWrapper({ 113 | settings: { 114 | isSSR: () => false, 115 | }, 116 | }) 117 | const { result, waitForNextUpdate, rerender } = renderHook( 118 | () => useApi(url), 119 | { wrapper } 120 | ) 121 | const [data, state, request] = result.current 122 | expect(data).toBeUndefined() 123 | expect(state).toEqual({ 124 | loading: true, 125 | fromCache: false, 126 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 127 | error: undefined, 128 | }) 129 | expect(request).toBeTruthy() 130 | 131 | await waitForNextUpdate() 132 | 133 | const [uData, uState] = result.current 134 | expect(uData).toEqual(apiData) 135 | expect(uState).toEqual({ 136 | loading: false, 137 | fromCache: false, 138 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 139 | error: undefined, 140 | prevData: undefined, 141 | prevState: { 142 | loading: true, 143 | fromCache: false, 144 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 145 | error: undefined, 146 | }, 147 | response: { status: 200, data: apiData, headers: undefined }, 148 | dependencies: undefined, 149 | data: apiData, 150 | }) 151 | 152 | rerender() 153 | 154 | // should be same after rerender 155 | const [nData, nState] = result.current 156 | expect(nData).toBe(uData) 157 | expect(nState).toBe(uState) 158 | }) 159 | 160 | it('should work well with cache data', async () => { 161 | console.log = jest.fn() 162 | const cache = new LRU() 163 | const context = { 164 | settings: { 165 | cache, 166 | isSSR: () => false, 167 | debug: true, 168 | }, 169 | } as ReactUseApi.CustomContext 170 | const cacheKey = '{"url":"/api/v1/foo/bar"}' 171 | cache.set(cacheKey, { 172 | response: { 173 | data: apiData, 174 | }, 175 | }) 176 | const wrapper = createWrapper(context) 177 | const { result, rerender } = renderHook( 178 | () => 179 | useApi({ 180 | url, 181 | }), 182 | { wrapper } 183 | ) 184 | const [data, state] = result.current 185 | expect(console.log).toHaveBeenCalledWith('[ReactUseApi][Feed]', cacheKey) 186 | expect(data).toEqual(apiData) 187 | expect(state).toEqual({ 188 | loading: false, 189 | fromCache: true, 190 | $cacheKey: cacheKey, 191 | error: undefined, 192 | prevData: undefined, 193 | prevState: initState, 194 | response: { 195 | data: apiData, 196 | }, 197 | dependencies: undefined, 198 | data: apiData, 199 | }) 200 | 201 | // cannot use waitForNextUpdate to test the non-rerender situation, use rerender instead 202 | // await waitForNextUpdate() 203 | rerender() 204 | 205 | const [uData, uState] = result.current 206 | expect(uData).toBe(data) 207 | expect(uState).toEqual(state) 208 | }) 209 | 210 | describe('SSR tests', () => { 211 | const cache = new LRU() 212 | beforeEach(() => { 213 | cache.reset() 214 | console.log = jest.fn() 215 | }) 216 | 217 | it('should work well with cache data', async () => { 218 | const context = { 219 | settings: { 220 | cache, 221 | isSSR: () => true, 222 | debug: true, 223 | }, 224 | } as ReactUseApi.CustomContext 225 | const cacheKey = '{"url":"/api/v1/foo/bar"}' 226 | cache.set(cacheKey, { 227 | response: { 228 | data: apiData, 229 | }, 230 | }) 231 | const wrapper = createWrapper(context) 232 | const { result, waitForNextUpdate, rerender } = renderHook( 233 | () => 234 | useApi({ 235 | url, 236 | }), 237 | { wrapper } 238 | ) 239 | const [data, state] = result.current 240 | expect(console.log).toHaveBeenCalledWith('[ReactUseApi][Feed]', cacheKey) 241 | expect(data).toEqual(apiData) 242 | expect(state).toEqual({ 243 | loading: false, 244 | fromCache: false, 245 | $cacheKey: cacheKey, 246 | error: undefined, 247 | prevData: undefined, 248 | prevState: initState, 249 | response: { 250 | data: apiData, 251 | }, 252 | dependencies: undefined, 253 | data: apiData, 254 | }) 255 | 256 | // cannot use waitForNextUpdate to test the non-rerender situation, use rerender instead 257 | // await waitForNextUpdate() 258 | rerender() 259 | 260 | const [uData, uState] = result.current 261 | expect(uData).toBe(data) 262 | expect(uState).toEqual(state) 263 | }) 264 | 265 | it('should work well without cache data', async () => { 266 | const context = { 267 | settings: { 268 | cache, 269 | isSSR: () => true, 270 | debug: true, 271 | }, 272 | } as ReactUseApi.CustomContext 273 | const cacheKey = '{"url":"/api/v1/foo/bar"}' 274 | const wrapper = createWrapper(context) 275 | renderHook( 276 | () => 277 | useApi({ 278 | url, 279 | }), 280 | { wrapper } 281 | ) 282 | const { 283 | collection: { ssrConfigs, cacheKeys }, 284 | } = context 285 | expect(console.log).toHaveBeenCalledWith( 286 | '[ReactUseApi][Collect]', 287 | cacheKey 288 | ) 289 | expect(ssrConfigs).toEqual([ 290 | { 291 | config: { 292 | url, 293 | }, 294 | cacheKey, 295 | }, 296 | ]) 297 | expect(cacheKeys.size).toBe(1) 298 | }) 299 | }) 300 | 301 | it('should request work well without options', async () => { 302 | const wrapper = createWrapper({ 303 | settings: { 304 | isSSR: () => false, 305 | }, 306 | }) 307 | const { result, waitForNextUpdate } = renderHook( 308 | () => 309 | useApi({ 310 | url, 311 | }), 312 | { wrapper } 313 | ) 314 | const [, , request] = result.current 315 | await waitForNextUpdate() 316 | 317 | act(() => { 318 | request() 319 | }) 320 | 321 | const [data, state] = result.current 322 | expect(data).toEqual(apiData) 323 | expect(state).toEqual({ 324 | loading: true, 325 | fromCache: false, 326 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 327 | error: undefined, 328 | prevData: undefined, 329 | prevState: { 330 | loading: true, 331 | fromCache: false, 332 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 333 | error: undefined, 334 | }, 335 | response: { status: 200, data: apiData, headers: undefined }, 336 | dependencies: undefined, 337 | data: apiData, 338 | }) 339 | 340 | await waitForNextUpdate() 341 | 342 | const [uData, uState] = result.current 343 | expect(uData).toEqual(apiData) 344 | expect(uState).toEqual({ 345 | loading: false, 346 | fromCache: false, 347 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 348 | error: undefined, 349 | prevData: { foo: { bar: true } }, 350 | response: { status: 200, data: apiData, headers: undefined }, 351 | dependencies: undefined, 352 | data: { foo: { bar: true } }, 353 | prevState: { 354 | loading: true, 355 | fromCache: false, 356 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 357 | error: undefined, 358 | prevData: undefined, 359 | response: { status: 200, data: apiData, headers: undefined }, 360 | dependencies: undefined, 361 | data: { foo: { bar: true } }, 362 | }, 363 | }) 364 | }) 365 | 366 | it('should request work well for advanced usage', async () => { 367 | const apiListUrl = '/api/v1/itemList' 368 | const apiListData = [ 369 | [1, 2], 370 | [3, 4], 371 | [5, 6], 372 | ] 373 | mock 374 | .onGet(apiListUrl, { 375 | params: { 376 | start: 0, 377 | count: 2, 378 | }, 379 | }) 380 | .reply(200, apiListData[0]) 381 | mock 382 | .onGet(apiListUrl, { 383 | params: { 384 | start: 2, 385 | count: 4, 386 | }, 387 | }) 388 | .reply(200, apiListData[1]) 389 | mock 390 | .onGet(apiListUrl, { 391 | params: { 392 | start: 4, 393 | count: 6, 394 | }, 395 | }) 396 | .reply(200, apiListData[2]) 397 | 398 | const wrapper = createWrapper({ 399 | settings: { 400 | isSSR: () => false, 401 | }, 402 | }) 403 | const { result, waitForNextUpdate } = renderHook( 404 | () => { 405 | const [myState, setMyState] = useState(0) 406 | const options = { 407 | dependencies: {}, 408 | handleData(data: ReactUseApi.Data, newState: ReactUseApi.State) { 409 | const { prevData = [] } = newState 410 | data = Array.isArray(data) ? data : [] 411 | // since React 16.9 supports calling a state setter inside useReducer 412 | setMyState(1) 413 | return [...prevData, ...data] 414 | }, 415 | } 416 | const result = useApi( 417 | { 418 | url: apiListUrl, 419 | params: { 420 | start: 0, 421 | count: 2, 422 | }, 423 | }, 424 | options 425 | ) 426 | return [...result, myState] 427 | }, 428 | { wrapper } 429 | ) 430 | await waitForNextUpdate() 431 | const [data, state, request] = result.current 432 | expect(data).toEqual(apiListData[0]) 433 | 434 | act(() => { 435 | request( 436 | { 437 | url: apiListUrl, 438 | params: { 439 | start: 2, 440 | count: 4, 441 | }, 442 | }, 443 | true 444 | ) 445 | }) 446 | await waitForNextUpdate() 447 | const [nData, nState, , myState] = result.current 448 | expect(nData).toEqual([...apiListData[0], ...apiListData[1]]) 449 | expect(nState.$cacheKey).toEqual(state.$cacheKey) 450 | expect(myState).toBe(1) 451 | 452 | act(() => { 453 | request( 454 | { 455 | url: apiListUrl, 456 | params: { 457 | start: 4, 458 | count: 6, 459 | }, 460 | }, 461 | true 462 | ) 463 | }) 464 | await waitForNextUpdate() 465 | const [uData, uState] = result.current 466 | expect(uData).toEqual([ 467 | ...apiListData[0], 468 | ...apiListData[1], 469 | ...apiListData[2], 470 | ]) 471 | expect(uState.$cacheKey).toEqual(state.$cacheKey) 472 | }) 473 | 474 | it('should shouldRequest work well', async () => { 475 | const wrapper = createWrapper({ 476 | settings: { 477 | isSSR: () => false, 478 | }, 479 | }) 480 | let shouldFetchData = false 481 | const ref = { current: { isRequesting: false } } 482 | const spy = jest.spyOn(React, 'useRef').mockReturnValue(ref) 483 | const options = { 484 | shouldRequest() { 485 | if (shouldFetchData) { 486 | shouldFetchData = false 487 | return true 488 | } 489 | return false 490 | }, 491 | } 492 | const { result, waitForNextUpdate, rerender } = renderHook( 493 | () => 494 | useApi( 495 | { 496 | url, 497 | }, 498 | options 499 | ), 500 | { wrapper } 501 | ) 502 | await waitForNextUpdate() 503 | expect(ref.current.isRequesting).toBe(false) 504 | 505 | // first rerender test 506 | const [, state] = result.current 507 | rerender() 508 | 509 | const [nData, nState] = result.current 510 | // should be same 511 | expect(nState).toBe(state) 512 | 513 | shouldFetchData = true 514 | rerender() 515 | expect(ref.current.isRequesting).toBe(true) 516 | await waitForNextUpdate() 517 | 518 | const [uData, uState] = result.current 519 | expect(uData).not.toBe(nData) 520 | expect(uData).toEqual(apiData) 521 | expect(uState).toEqual({ 522 | loading: false, 523 | fromCache: false, 524 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 525 | prevData: apiData, 526 | response: { status: 200, data: apiData, headers: undefined }, 527 | dependencies: undefined, 528 | error: undefined, 529 | data: apiData, 530 | prevState: { 531 | loading: true, 532 | fromCache: false, 533 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 534 | prevData: undefined, 535 | response: { status: 200, data: apiData, headers: undefined }, 536 | dependencies: undefined, 537 | error: undefined, 538 | data: apiData, 539 | }, 540 | }) 541 | expect(ref.current.isRequesting).toBe(false) 542 | spy.mockRestore() 543 | }) 544 | 545 | it('should shouldUseApiCache work well', async () => { 546 | console.log = jest.fn() 547 | const cache = new LRU() 548 | const context = { 549 | settings: { 550 | cache, 551 | debug: true, 552 | isSSR: () => false, 553 | shouldUseApiCache: (config, cacheKey) => { 554 | if (cacheKey.includes('/no/cache')) { 555 | return false 556 | } 557 | return true 558 | }, 559 | }, 560 | } as ReactUseApi.CustomContext 561 | const cacheKey = '{"url":"/api/v1/foo/bar"}' 562 | cache.set(cacheKey, { 563 | response: { 564 | data: apiData, 565 | }, 566 | }) 567 | const noCacheData = { 568 | no: 'dont touch me', 569 | } 570 | const noCacheApiUrl = '/api/v1/no/cache' 571 | cache.set(`{"url":"${noCacheApiUrl}"}`, { 572 | response: { 573 | data: noCacheData, 574 | }, 575 | }) 576 | mock.onGet(noCacheApiUrl).reply(200, { 577 | foo: 'bar', 578 | }) 579 | const wrapper = createWrapper(context) 580 | const { result, rerender, waitForNextUpdate } = renderHook( 581 | () => { 582 | const [data1] = useApi(url) 583 | const [data2] = useApi(noCacheApiUrl) 584 | return [data1, data2] 585 | }, 586 | { wrapper } 587 | ) 588 | 589 | await waitForNextUpdate() 590 | 591 | const [data1, data2] = result.current 592 | expect(data1).toEqual(apiData) 593 | expect(data2).not.toEqual(noCacheData) 594 | }) 595 | 596 | it('should watch work well', async () => { 597 | const wrapper = createWrapper({ 598 | settings: { 599 | isSSR: () => false, 600 | }, 601 | }) 602 | const watch = [123, 456] 603 | const options = { 604 | watch, 605 | } 606 | const { result, waitForNextUpdate, rerender } = renderHook( 607 | () => 608 | useApi( 609 | { 610 | url, 611 | }, 612 | options 613 | ), 614 | { wrapper } 615 | ) 616 | await waitForNextUpdate() 617 | 618 | // first rerender test 619 | const [, state] = result.current 620 | rerender() 621 | 622 | const [nData, nState] = result.current 623 | // should be same 624 | expect(nState).toBe(state) 625 | 626 | watch[1] = 123 627 | rerender() 628 | await waitForNextUpdate() 629 | 630 | const [uData, uState] = result.current 631 | expect(uData).not.toBe(nData) 632 | expect(uData).toEqual(apiData) 633 | expect(uState).toEqual({ 634 | loading: false, 635 | fromCache: false, 636 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 637 | prevData: apiData, 638 | response: { status: 200, data: apiData, headers: undefined }, 639 | dependencies: undefined, 640 | error: undefined, 641 | data: apiData, 642 | prevState: { 643 | loading: true, 644 | fromCache: false, 645 | $cacheKey: '{"url":"/api/v1/foo/bar"}', 646 | prevData: undefined, 647 | response: { status: 200, data: apiData, headers: undefined }, 648 | dependencies: undefined, 649 | error: undefined, 650 | data: apiData, 651 | }, 652 | }) 653 | }) 654 | 655 | it('should multiple requests work well', async () => { 656 | const wrapper = createWrapper({ 657 | settings: { 658 | isSSR: () => false, 659 | }, 660 | }) 661 | const { result, waitForNextUpdate } = renderHook( 662 | () => 663 | useApi([ 664 | { 665 | url, 666 | }, 667 | { url: listUrl }, 668 | ]), 669 | { wrapper } 670 | ) 671 | await waitForNextUpdate() 672 | 673 | const [data, state] = result.current 674 | expect(data).toEqual([apiData, listData]) 675 | expect(state).toEqual({ 676 | loading: false, 677 | fromCache: false, 678 | $cacheKey: '[{"url":"/api/v1/foo/bar"},{"url":"/api/v1/list"}]', 679 | error: undefined, 680 | prevData: undefined, 681 | prevState: { 682 | loading: true, 683 | fromCache: false, 684 | $cacheKey: '[{"url":"/api/v1/foo/bar"},{"url":"/api/v1/list"}]', 685 | error: undefined, 686 | }, 687 | response: [ 688 | { status: 200, data: apiData, headers: undefined }, 689 | { status: 200, data: listData, headers: undefined }, 690 | ], 691 | dependencies: undefined, 692 | data: [apiData, listData], 693 | }) 694 | }) 695 | 696 | it('should work as expected about multiple requests, even if one of them fails', async () => { 697 | const wrapper = createWrapper({ 698 | settings: { 699 | isSSR: () => false, 700 | }, 701 | }) 702 | const { result, waitForNextUpdate } = renderHook( 703 | () => 704 | useApi([ 705 | { 706 | url, 707 | }, 708 | { url: errorUrl }, 709 | ]), 710 | { wrapper } 711 | ) 712 | await waitForNextUpdate() 713 | 714 | const [data, state] = result.current 715 | expect(data).toBeUndefined() 716 | expect(state.response).toBeUndefined() 717 | expect(state.error.response.data).toEqual(errorData) 718 | }) 719 | 720 | it('should HTTP error request work as expected', async () => { 721 | const wrapper = createWrapper({ 722 | settings: { 723 | isSSR: () => false, 724 | }, 725 | }) 726 | const { result, waitForNextUpdate } = renderHook(() => useApi(errorUrl), { 727 | wrapper, 728 | }) 729 | await waitForNextUpdate() 730 | 731 | const [data, state] = result.current 732 | expect(data).toBeUndefined() 733 | expect(state.response).toBeUndefined() 734 | expect(state.error.response.data).toEqual(errorData) 735 | }) 736 | 737 | it('should skip work well', async () => { 738 | const cache = new LRU() 739 | const context = { 740 | settings: { 741 | cache, 742 | isSSR: () => false, 743 | }, 744 | } as ReactUseApi.CustomContext 745 | const wrapper = createWrapper(context) 746 | const { result, waitForNextUpdate, rerender } = renderHook( 747 | () => 748 | useApi( 749 | { 750 | url, 751 | }, 752 | { 753 | skip: true, 754 | } 755 | ), 756 | { wrapper } 757 | ) 758 | 759 | const [data, state] = result.current 760 | expect(data).toBeUndefined() 761 | expect(state).toEqual(initState) 762 | 763 | // cannot use waitForNextUpdate to test the non-rerender situation, use rerender instead 764 | // await waitForNextUpdate() 765 | rerender() 766 | 767 | const [uData, uState] = result.current 768 | expect(uData).toBeUndefined() 769 | expect(uState).toEqual(initState) 770 | }) 771 | 772 | describe('useCache tests', () => { 773 | const cache = new LRU() 774 | const context = { 775 | settings: { 776 | cache, 777 | isSSR: () => false, 778 | }, 779 | } as ReactUseApi.Context 780 | beforeEach(() => { 781 | cache.reset() 782 | }) 783 | 784 | it('should work well with cache data come from server side', async () => { 785 | const cacheKey = '{"url":"/api/v1/foo/bar"}' 786 | const cacheData = { 787 | data: { 788 | msg: 'this is cached data', 789 | }, 790 | } 791 | cache.set(cacheKey, { 792 | response: { 793 | data: cacheData, 794 | }, 795 | }) 796 | const wrapper = createWrapper(context) 797 | const { result, waitForNextUpdate, rerender } = renderHook( 798 | () => 799 | useApi( 800 | { 801 | url, 802 | }, 803 | { 804 | useCache: true, 805 | } 806 | ), 807 | { wrapper } 808 | ) 809 | const [data] = result.current 810 | expect(data).toEqual(cacheData) 811 | 812 | // render by the same api again 813 | const { result: result2 } = renderHook( 814 | () => 815 | useApi( 816 | { 817 | url, 818 | }, 819 | { 820 | useCache: true, 821 | } 822 | ), 823 | { wrapper } 824 | ) 825 | const [data2] = result2.current 826 | expect(data2).toEqual(cacheData) 827 | }) 828 | 829 | it('should work well without cache data come from server side', async () => { 830 | const cacheKey = '{"url":"/api/v1/foo/bar"}' 831 | const cacheData = { 832 | msg: 'this is cached data', 833 | } 834 | mock.onGet(url).reply(200, cacheData) 835 | const wrapper = createWrapper(context) 836 | const { result, waitForNextUpdate } = renderHook( 837 | () => 838 | useApi( 839 | { 840 | url, 841 | }, 842 | { 843 | useCache: true, 844 | } 845 | ), 846 | { wrapper } 847 | ) 848 | 849 | await waitForNextUpdate() 850 | 851 | const [data] = result.current 852 | expect(data).toEqual(cacheData) 853 | expect(cache.get(cacheKey)).toEqual({ 854 | error: undefined, 855 | response: { 856 | data: cacheData, 857 | headers: undefined, 858 | status: 200, 859 | }, 860 | }) 861 | 862 | // render by the same api again, use cache data still by default 863 | const { result: result2 } = renderHook( 864 | () => 865 | useApi({ 866 | url, 867 | }), 868 | { wrapper } 869 | ) 870 | 871 | const [data2] = result2.current 872 | expect(data2).toEqual(cacheData) 873 | 874 | // render by the same api again but useCache=false 875 | const { 876 | result: result3, 877 | waitForNextUpdate: waitForNextUpdate3, 878 | } = renderHook( 879 | () => 880 | useApi( 881 | { 882 | url, 883 | }, 884 | { 885 | useCache: false, 886 | } 887 | ), 888 | { wrapper } 889 | ) 890 | 891 | await waitForNextUpdate3() 892 | 893 | const [data3] = result3.current 894 | expect(data3).toEqual(cacheData) 895 | }) 896 | 897 | it('should work with multiple same api calls', async () => { 898 | const url = '/api/v3/cache/test' 899 | const cacheKey = `{"url":"${url}"}` 900 | const cacheData = { 901 | msg: 'this is cached data', 902 | } 903 | mock.onGet(url).reply(200, cacheData) 904 | const wrapper = createWrapper(context) 905 | const { result, waitForNextUpdate } = renderHook( 906 | () => { 907 | const [data1] = useApi(url, { useCache: true }) 908 | const [data2] = useApi(url) 909 | const [data3, , request] = useApi(url, { useCache: false }) 910 | return [data1, data2, data3, request] 911 | }, 912 | { wrapper } 913 | ) 914 | 915 | await waitForNextUpdate() 916 | 917 | const [data1, data2, data3, request] = result.current 918 | expect(data1).toEqual(cacheData) 919 | expect(data2).toEqual(cacheData) 920 | expect(data3).toEqual(cacheData) 921 | expect(cache.get(cacheKey)).toEqual({ 922 | error: undefined, 923 | response: { 924 | data: cacheData, 925 | headers: undefined, 926 | status: 200, 927 | }, 928 | }) 929 | const newCacheData = { 930 | msg: 'cached data 2', 931 | } 932 | mock.onGet(url).reply(200, newCacheData) 933 | await act(async () => { 934 | request() 935 | }) 936 | const [, , ndata3] = result.current 937 | expect(ndata3).toEqual(newCacheData) 938 | }) 939 | 940 | it('should work with multiple same api calls invoked by nested components and clearLastCacheWhenConfigChanges=false', async () => { 941 | const url = '/api/v3/api/test' 942 | const apiData = { 943 | msg: 'this is api test data', 944 | } 945 | const urlMock = jest.fn().mockReturnValue([200, apiData]) 946 | mock.onGet(url).reply(urlMock) 947 | 948 | const newUrl = `${url}?foo=bar` 949 | const newApiData = { 950 | msg: 'this is api test data 2', 951 | } 952 | const newUrlMock = jest.fn().mockReturnValue([200, newApiData]) 953 | mock.onGet(newUrl).reply(newUrlMock) 954 | 955 | let currentUrl = url 956 | let childData 957 | const Child = () => { 958 | const [data] = useApi(currentUrl, { useCache: true }) 959 | childData = data 960 | return <>hello 961 | } 962 | const provide = (context: ReactUseApi.CustomContext) => ({ 963 | children, 964 | }) => { 965 | return ( 966 | 967 | {children} 968 | 969 | 970 | ) 971 | } 972 | const wrapper = provide({ 973 | settings: { 974 | cache, 975 | isSSR: () => false, 976 | clearLastCacheWhenConfigChanges: false, 977 | }, 978 | } as ReactUseApi.Context) 979 | 980 | const { result, waitForNextUpdate, rerender } = renderHook( 981 | () => { 982 | const [data1, request] = useApi(currentUrl, { useCache: true }) 983 | const [data2] = useApi(currentUrl, { useCache: false }) 984 | return [data1, data2, request] 985 | }, 986 | { wrapper } 987 | ) 988 | 989 | await waitForNextUpdate() 990 | 991 | const [data1, data2] = result.current 992 | expect(data1).toEqual(apiData) 993 | expect(data2).toEqual(apiData) 994 | expect(childData).toEqual(apiData) 995 | expect(urlMock.mock.calls.length).toBe(2) 996 | 997 | currentUrl = newUrl 998 | rerender() 999 | await waitForNextUpdate() 1000 | 1001 | expect(result.current[0]).toEqual(newApiData) 1002 | expect(result.current[1]).toEqual(newApiData) 1003 | expect(childData).toEqual(newApiData) 1004 | expect(urlMock.mock.calls.length).toBe(2) 1005 | 1006 | currentUrl = url 1007 | urlMock.mockClear() 1008 | rerender() 1009 | await waitForNextUpdate() 1010 | 1011 | expect(result.current[0]).toEqual(apiData) 1012 | expect(result.current[1]).toEqual(apiData) 1013 | expect(childData).toEqual(apiData) 1014 | expect(urlMock.mock.calls.length).toBe(1) 1015 | 1016 | // should have cache data 1017 | expect(cache.has(`{"url":"${url}"}`)).toBe(true) 1018 | expect(cache.has(`{"url":"${newUrl}"}`)).toBe(true) 1019 | }) 1020 | 1021 | it('should work with multiple same api calls invoked by nested components', async () => { 1022 | const url = '/api/v3/api/test' 1023 | const apiData = { 1024 | msg: 'this is api test data', 1025 | } 1026 | const urlMock = jest.fn().mockReturnValue([200, apiData]) 1027 | mock.onGet(url).reply(urlMock) 1028 | 1029 | const newUrl = `${url}?foo=bar` 1030 | const newApiData = { 1031 | msg: 'this is api test data 2', 1032 | } 1033 | const newUrlMock = jest.fn().mockReturnValue([200, newApiData]) 1034 | mock.onGet(newUrl).reply(newUrlMock) 1035 | 1036 | let currentUrl = url 1037 | let childData 1038 | const Child = () => { 1039 | const [data] = useApi(currentUrl, { useCache: true }) 1040 | childData = data 1041 | return <>hello 1042 | } 1043 | const provide = (context: ReactUseApi.CustomContext) => ({ 1044 | children, 1045 | }) => { 1046 | return ( 1047 | 1048 | {children} 1049 | 1050 | 1051 | ) 1052 | } 1053 | const wrapper = provide(context) 1054 | 1055 | const { result, waitForNextUpdate, rerender } = renderHook( 1056 | () => { 1057 | const [data1, request] = useApi(currentUrl, { useCache: true }) 1058 | const [data2] = useApi(currentUrl, { useCache: false }) 1059 | return [data1, data2, request] 1060 | }, 1061 | { wrapper } 1062 | ) 1063 | 1064 | await waitForNextUpdate() 1065 | 1066 | const [data1, data2] = result.current 1067 | expect(data1).toEqual(apiData) 1068 | expect(data2).toEqual(apiData) 1069 | expect(childData).toEqual(apiData) 1070 | expect(urlMock.mock.calls.length).toBe(2) 1071 | 1072 | currentUrl = newUrl 1073 | rerender() 1074 | expect(cache.has(`{"url":"${url}"}`)).toBe(false) 1075 | await waitForNextUpdate() 1076 | 1077 | expect(result.current[0]).toEqual(newApiData) 1078 | expect(result.current[1]).toEqual(newApiData) 1079 | expect(childData).toEqual(newApiData) 1080 | expect(urlMock.mock.calls.length).toBe(2) 1081 | 1082 | currentUrl = url 1083 | urlMock.mockClear() 1084 | rerender() 1085 | expect(cache.has(`{"url":"${newUrl}"}`)).toBe(false) 1086 | await waitForNextUpdate() 1087 | 1088 | expect(result.current[0]).toEqual(apiData) 1089 | expect(result.current[1]).toEqual(apiData) 1090 | expect(childData).toEqual(apiData) 1091 | expect(urlMock.mock.calls.length).toBe(2) 1092 | 1093 | // should have cache data 1094 | expect(cache.has(`{"url":"${url}"}`)).toBe(true) 1095 | }) 1096 | }) 1097 | }) 1098 | 1099 | describe('fetchApi tests', () => { 1100 | const cache = new LRU() 1101 | const context = { 1102 | settings: { axios, cache }, 1103 | } as ReactUseApi.Context 1104 | const url = '/api/v1/user' 1105 | const config = { 1106 | url, 1107 | } 1108 | const options = { 1109 | $cacheKey: url, 1110 | } 1111 | beforeEach(() => { 1112 | jest.restoreAllMocks() 1113 | cache.reset() 1114 | }) 1115 | 1116 | it('should loading and success work well', async () => { 1117 | const apiData = { 1118 | message: 'ok', 1119 | } 1120 | mock.onGet(url).reply(200, apiData) 1121 | const dispatch = jest.fn() 1122 | await fetchApi(context, config, options, dispatch) 1123 | expect(dispatch).toHaveBeenCalledWith({ 1124 | type: ACTIONS.REQUEST_START, 1125 | options, 1126 | }) 1127 | expect(dispatch).toHaveBeenCalledWith({ 1128 | type: ACTIONS.REQUEST_END, 1129 | response: { 1130 | data: apiData, 1131 | headers: undefined, 1132 | status: 200, 1133 | }, 1134 | error: undefined, 1135 | options, 1136 | fromCache: false, 1137 | }) 1138 | }) 1139 | 1140 | it('should loading and error work well', async () => { 1141 | const apiData = { 1142 | message: 'fail', 1143 | } 1144 | mock.onGet(url).reply(500, apiData) 1145 | const dispatch = jest.fn() 1146 | await fetchApi(context, config, options, dispatch) 1147 | expect(dispatch).toHaveBeenCalledWith({ 1148 | type: ACTIONS.REQUEST_START, 1149 | options, 1150 | }) 1151 | const args = dispatch.mock.calls[1][0] 1152 | expect(args.type).toEqual(ACTIONS.REQUEST_END) 1153 | expect(args.error.response).toEqual({ 1154 | data: apiData, 1155 | headers: undefined, 1156 | status: 500, 1157 | }) 1158 | expect(args.response).toBeUndefined() 1159 | expect(args.options).toEqual(options) 1160 | }) 1161 | 1162 | it('should loading and success work well by cache data', async () => { 1163 | const apiData = { 1164 | message: 'ok', 1165 | } 1166 | cache.set(url, { 1167 | response: { 1168 | data: apiData, 1169 | }, 1170 | }) 1171 | const dispatch = jest.fn() 1172 | const opt = { 1173 | ...options, 1174 | useCache: true, 1175 | } 1176 | await fetchApi(context, config, opt, dispatch) 1177 | expect(dispatch).not.toHaveBeenCalledWith({ 1178 | type: ACTIONS.REQUEST_START, 1179 | options: opt, 1180 | }) 1181 | expect(dispatch).toHaveBeenCalledWith({ 1182 | type: ACTIONS.REQUEST_END, 1183 | response: { 1184 | data: apiData, 1185 | }, 1186 | error: undefined, 1187 | fromCache: true, 1188 | options: opt, 1189 | }) 1190 | }) 1191 | 1192 | it('should loading and error work well by cache data', async () => { 1193 | const apiData = { 1194 | message: 'fail', 1195 | } 1196 | cache.set(url, { 1197 | error: { 1198 | data: apiData, 1199 | }, 1200 | }) 1201 | const opt = { 1202 | ...options, 1203 | useCache: true, 1204 | } 1205 | const dispatch = jest.fn() 1206 | await fetchApi(context, config, opt, dispatch) 1207 | expect(dispatch).not.toHaveBeenCalledWith({ 1208 | type: ACTIONS.REQUEST_START, 1209 | options: opt, 1210 | }) 1211 | const args = dispatch.mock.calls[0][0] 1212 | expect(args.type).toEqual(ACTIONS.REQUEST_END) 1213 | expect(args.error).toEqual({ 1214 | data: apiData, 1215 | }) 1216 | expect(args.response).toBeUndefined() 1217 | expect(args.options).toEqual(opt) 1218 | }) 1219 | }) 1220 | 1221 | describe('reducer tests', () => { 1222 | it('should get initState from REQUEST_START', () => { 1223 | const state = { ...initState } 1224 | const action = { 1225 | type: ACTIONS.REQUEST_START, 1226 | options: { 1227 | $cacheKey: '/foo/bar', 1228 | }, 1229 | } 1230 | const newState = reducer(state, action) 1231 | expect(newState).not.toBe(state) 1232 | expect(newState).toEqual({ 1233 | ...state, 1234 | loading: true, 1235 | fromCache: false, 1236 | error: undefined, 1237 | $cacheKey: '/foo/bar', 1238 | }) 1239 | }) 1240 | 1241 | it('should get previous state with loading = true from REQUEST_START', () => { 1242 | const state = { 1243 | myData: '123', 1244 | fromCache: false, 1245 | loading: false, 1246 | $cacheKey: '/foo/bar', 1247 | } as ReactUseApi.State 1248 | const action = { 1249 | type: ACTIONS.REQUEST_START, 1250 | options: { 1251 | $cacheKey: '/foo/bar', 1252 | }, 1253 | } 1254 | const newState = reducer(state, action) 1255 | expect(newState).not.toBe(state) 1256 | expect(newState).toEqual({ 1257 | ...state, 1258 | loading: true, 1259 | fromCache: false, 1260 | error: undefined, 1261 | $cacheKey: '/foo/bar', 1262 | }) 1263 | }) 1264 | 1265 | it('should reset to initState from REQUEST_START if cacheKey changes', () => { 1266 | const state = { 1267 | myData: '123', 1268 | fromCache: false, 1269 | loading: false, 1270 | $cacheKey: '/foo/bar', 1271 | } as ReactUseApi.State 1272 | const action = { 1273 | type: ACTIONS.REQUEST_START, 1274 | options: { 1275 | $cacheKey: '/abc/def', 1276 | }, 1277 | } 1278 | const newState = reducer(state, action) 1279 | expect(newState).not.toBe(state) 1280 | expect(newState).toEqual({ 1281 | loading: true, 1282 | fromCache: false, 1283 | error: undefined, 1284 | $cacheKey: '/abc/def', 1285 | }) 1286 | }) 1287 | 1288 | it('should REQUEST_END work well if response', () => { 1289 | const state = { ...initState } 1290 | const action = { 1291 | type: ACTIONS.REQUEST_END, 1292 | response: { 1293 | data: { 1294 | message: 'ok', 1295 | }, 1296 | } as any, 1297 | options: { 1298 | dependencies: { 1299 | foo: 'bar', 1300 | }, 1301 | $cacheKey: '/foo/bar', 1302 | }, 1303 | } as ReactUseApi.Action 1304 | const newState = reducer(state, action) 1305 | expect(newState).not.toBe(state) 1306 | expect(newState).toEqual({ 1307 | loading: false, 1308 | fromCache: false, 1309 | prevData: undefined, 1310 | prevState: initState, 1311 | response: { data: { message: 'ok' } }, 1312 | dependencies: { foo: 'bar' }, 1313 | error: undefined, 1314 | data: { message: 'ok' }, 1315 | $cacheKey: '/foo/bar', 1316 | }) 1317 | }) 1318 | 1319 | it('should REQUEST_END work well if error', () => { 1320 | const state = { ...initState } 1321 | const action = { 1322 | type: ACTIONS.REQUEST_END, 1323 | error: { 1324 | data: { 1325 | message: 'fail', 1326 | }, 1327 | } as any, 1328 | options: { 1329 | dependencies: { 1330 | foo: 'bar', 1331 | }, 1332 | $cacheKey: '/foo/bar', 1333 | }, 1334 | } as ReactUseApi.Action 1335 | const newState = reducer(state, action) 1336 | expect(newState).not.toBe(state) 1337 | expect(newState).toEqual({ 1338 | loading: false, 1339 | fromCache: false, 1340 | prevData: undefined, 1341 | prevState: initState, 1342 | response: undefined, 1343 | dependencies: { foo: 'bar' }, 1344 | error: { data: { message: 'fail' } }, 1345 | data: undefined, 1346 | $cacheKey: '/foo/bar', 1347 | }) 1348 | }) 1349 | 1350 | it('should get the same state if the action is not found', () => { 1351 | const state = { ...initState } 1352 | const action = { 1353 | type: 'NOT_FOUND', 1354 | options: { 1355 | $cacheKey: '/foo/bar', 1356 | }, 1357 | } 1358 | const newState = reducer(state, action) 1359 | expect(newState).toBe(state) 1360 | }) 1361 | }) 1362 | 1363 | describe('handleUseApiOptions tests', () => { 1364 | it('should work well with object options', () => { 1365 | const opt = { 1366 | watch: [123, 456], 1367 | handleData: () => null, 1368 | shouldRequest: () => false, 1369 | dependencies: {}, 1370 | } 1371 | const options = handleUseApiOptions( 1372 | opt, 1373 | { 1374 | shouldUseApiCache: jest.fn() as any, 1375 | } as ReactUseApi.Settings, 1376 | '/foo/bar', 1377 | '/foo/bar' 1378 | ) 1379 | expect(options).toEqual({ 1380 | ...opt, 1381 | $cacheKey: '/foo/bar', 1382 | }) 1383 | }) 1384 | it('should work well with watch options', () => { 1385 | const watch = [123, 456] 1386 | const options = handleUseApiOptions( 1387 | watch, 1388 | { 1389 | shouldUseApiCache: jest.fn() as any, 1390 | } as ReactUseApi.Settings, 1391 | '/foo/bar', 1392 | '/foo/bar' 1393 | ) 1394 | expect(options).toEqual({ 1395 | watch, 1396 | handleData: undefined, 1397 | $cacheKey: '/foo/bar', 1398 | }) 1399 | }) 1400 | it('should work well with handleData options', () => { 1401 | const handleData = jest.fn() 1402 | const options = handleUseApiOptions( 1403 | handleData, 1404 | { 1405 | shouldUseApiCache: jest.fn() as any, 1406 | } as ReactUseApi.Settings, 1407 | '/foo/bar', 1408 | '/foo/bar' 1409 | ) 1410 | expect(options).toEqual({ 1411 | watch: [], 1412 | handleData, 1413 | $cacheKey: '/foo/bar', 1414 | }) 1415 | }) 1416 | 1417 | it('should work well with settings.alwaysUseCache', () => { 1418 | const handleData = jest.fn() 1419 | const settings = { 1420 | alwaysUseCache: true, 1421 | shouldUseApiCache: jest.fn() as any, 1422 | } as ReactUseApi.Settings 1423 | const options = handleUseApiOptions( 1424 | handleData, 1425 | settings, 1426 | '/foo/bar', 1427 | '/foo/bar' 1428 | ) 1429 | expect(options).toEqual({ 1430 | watch: [], 1431 | handleData, 1432 | $cacheKey: '/foo/bar', 1433 | useCache: true, 1434 | }) 1435 | }) 1436 | 1437 | it('should options.useCache = false when settings.shouldUseApiCache returns false', () => { 1438 | const handleData = jest.fn() 1439 | const settings = { 1440 | shouldUseApiCache: jest.fn(() => false) as any, 1441 | } as ReactUseApi.Settings 1442 | const options = handleUseApiOptions( 1443 | handleData, 1444 | settings, 1445 | '/foo/bar', 1446 | '/foo/bar' 1447 | ) 1448 | expect(options).toEqual({ 1449 | watch: [], 1450 | handleData, 1451 | $cacheKey: '/foo/bar', 1452 | useCache: false, 1453 | }) 1454 | }) 1455 | }) 1456 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosStatic, AxiosInstance, AxiosError } from 'axios' 2 | import LRU from 'lru-cache' 3 | 4 | // export const cacheKeySymbol: unique symbol = Symbol('cacheKey') TS still not friendly to symbol... 5 | export const defaultSettings = { 6 | cache: new LRU(), 7 | axios: axios as AxiosStatic | AxiosInstance, 8 | maxRequests: 100, // max requests count when running ReactDom.renderToString in SSR 9 | useCacheData: true, // whether to use the cached api data come from server 10 | alwaysUseCache: false, // whether to use the cached api data always for each api call 11 | clearLastCacheWhenConfigChanges: true, // clear the last cache data with the last config when the config changes 12 | debug: false, 13 | clientCacheVar: '__USE_API_CACHE__', // the property name of window to save the cached api data come from server side 14 | isSSR: (...args: any[]): boolean | void => typeof window === 'undefined', 15 | renderSSR: (...args: any[]): string => '', // a callback to render SSR HTML string 16 | shouldUseApiCache: ( 17 | config?: ReactUseApi.Config, 18 | cacheKey?: string 19 | ): boolean | void => true, // a callback to decide whether to use the cached api data 20 | } 21 | export const ACTIONS = { 22 | REQUEST_START: 'REQUEST_START', 23 | REQUEST_END: 'REQUEST_END', 24 | } 25 | export const initState = { 26 | loading: false, 27 | fromCache: false, 28 | $cacheKey: '', 29 | } 30 | 31 | export const configure = ( 32 | context: ReactUseApi.CustomContext, 33 | isSSR = false 34 | ) => { 35 | if (context.isConfigured) { 36 | return context as ReactUseApi.Context 37 | } 38 | const { settings: custom } = context 39 | const settings = { ...defaultSettings } 40 | if (isObject(custom)) { 41 | Object.keys(custom).forEach((key: keyof ReactUseApi.Settings) => { 42 | const value = custom[key] 43 | if (defaultSettings.hasOwnProperty(key) && !isNil(value)) { 44 | // @ts-ignore 45 | settings[key] = value 46 | } 47 | }) 48 | } 49 | isSSR = 50 | isSSR !== true && isFunction(settings.isSSR) ? !!settings.isSSR() : isSSR 51 | Object.assign(context, { 52 | settings, 53 | isSSR, 54 | isConfigured: true, 55 | collection: { 56 | ssrConfigs: [], 57 | cacheKeys: new Set(), 58 | } as ReactUseApi.SSRCollection, 59 | clearCache() { 60 | settings?.cache?.reset() 61 | }, 62 | }) 63 | return context as ReactUseApi.Context 64 | } 65 | 66 | export async function axiosAll( 67 | context: ReactUseApi.Context, 68 | config: ReactUseApi.Config 69 | ): Promise 70 | export async function axiosAll( 71 | context: ReactUseApi.Context, 72 | config: ReactUseApi.MultiConfigs 73 | ): Promise 74 | export async function axiosAll( 75 | context: ReactUseApi.Context, 76 | config: ReactUseApi.Config | ReactUseApi.MultiConfigs 77 | ): Promise { 78 | const { 79 | settings: { axios: client }, 80 | } = context 81 | const isMulti = Array.isArray(config) 82 | const requests = ((isMulti 83 | ? config 84 | : [config]) as ReactUseApi.MultiConfigs).map((cfg) => client(cfg)) 85 | try { 86 | const responses = await Promise.all(requests) 87 | responses.forEach(tidyResponse) 88 | const response = responses.length === 1 ? responses[0] : responses 89 | return response 90 | } catch (error) { 91 | const { response } = error as ReactUseApi.CacheData['error'] 92 | if (response) { 93 | tidyResponse(response) 94 | } 95 | throw error as AxiosError 96 | } 97 | } 98 | 99 | // for cache 100 | export const tidyResponse = (response: ReactUseApi.ApiResponse) => { 101 | if (response) { 102 | delete response.config 103 | delete response.request 104 | } 105 | return response 106 | } 107 | 108 | export const getResponseData = ( 109 | options: ReactUseApi.Options, 110 | state: ReactUseApi.State 111 | ) => { 112 | const { response } = state 113 | const isMultiApis = Array.isArray(response) 114 | let data = isMultiApis 115 | ? (response as ReactUseApi.ApiResponse[]).map((each) => each.data) 116 | : (response as ReactUseApi.ApiResponse).data 117 | const { handleData } = options 118 | if (isFunction(handleData)) { 119 | data = handleData(data, state) 120 | } 121 | return data 122 | } 123 | 124 | export const isObject = (target: any) => 125 | !!target && Object.prototype.toString.call(target) === '[object Object]' 126 | 127 | export const isFunction = (target: any) => 128 | !!target && typeof target === 'function' 129 | 130 | export const isNil = (value: any) => 131 | value === undefined || value === null || value === '' 132 | -------------------------------------------------------------------------------- /src/context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | export const ApiContext = createContext({}) 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default, useApi } from './useApi' 2 | export { injectSSRHtml, loadApiCache } from './ssr' 3 | export { ApiProvider } from './ApiProvider' 4 | export { ApiContext } from './context' 5 | -------------------------------------------------------------------------------- /src/ssr.ts: -------------------------------------------------------------------------------- 1 | import { configure, axiosAll, defaultSettings, isFunction } from './common' 2 | 3 | export const feedRequests = async ( 4 | context: ReactUseApi.Context, 5 | ssrHtml: string, 6 | maxRequests = context.settings.maxRequests 7 | ) => { 8 | const { 9 | settings, 10 | collection: { ssrConfigs, cacheKeys }, 11 | } = context 12 | const { cache, renderSSR, debug } = settings 13 | if (!ssrConfigs.length) { 14 | if (debug) { 15 | console.log('[ReactUseApi][Requests Count] =', cacheKeys.size) 16 | console.log( 17 | '[ReactUseApi][Executed Times] =', 18 | settings.maxRequests - maxRequests 19 | ) 20 | } 21 | cacheKeys.clear() 22 | return ssrHtml // done 23 | } 24 | debug && console.log('[ReactUseApi][Collecting Requests]') 25 | if (maxRequests === 0) { 26 | console.error( 27 | '[ReactUseApi][ERROR] - Maximum executing times while fetching axios requests', 28 | '[congis]:', 29 | ssrConfigs, 30 | '[Executed Times]:', 31 | settings.maxRequests - maxRequests 32 | ) 33 | cacheKeys.clear() 34 | return ssrHtml // done 35 | } 36 | 37 | // The algorithm is collecting the unobtained API request config from the previous renderSSR() 38 | // , but here only fetches the API data from the first config and again uses renderSSR() to feed the data to its components. 39 | // This approach may look like inefficient but rather stable, since each config may rely on the data from useApi(). 40 | // However, it is possible that no one request config that depends on another one, only one renderSSR() is needed 41 | // , but who can guarantee that every developer is able to consider this dependency? 42 | // react-apollo uses the similar algorithm 43 | // https://github.com/apollographql/react-apollo/blob/master/packages/ssr/src/getDataFromTree.ts 44 | const { config, cacheKey } = ssrConfigs[0] // fetch the first 45 | const cacheData = cache.get(cacheKey) 46 | if (!cacheData) { 47 | try { 48 | debug && console.log('[ReactUseApi][Fetch]', cacheKey) 49 | const response = await axiosAll(context, config) 50 | cache.set(cacheKey, { 51 | response, 52 | }) 53 | } catch (error) { 54 | // is an axios error 55 | if (error?.response?.data) { 56 | cache.set(cacheKey, { 57 | // should not be error (Error) object in SSR, it will lead an error: Converting circular structure to JSON 58 | error: error.response, 59 | }) 60 | } else { 61 | throw error 62 | } 63 | } 64 | } 65 | // execute renderSSR one after another to get more ssrConfigs 66 | ssrConfigs.length = 0 67 | ssrHtml = renderSSR() 68 | return await feedRequests(context, ssrHtml, --maxRequests) 69 | } 70 | 71 | export const injectSSRHtml = async ( 72 | context: ReactUseApi.CustomContext, 73 | renderSSR?: ReactUseApi.Settings['renderSSR'], 74 | postProcess?: (ssrHtml: string, apiCacheScript: string) => string 75 | ) => { 76 | context = configure(context, true) 77 | const { settings } = context 78 | settings.renderSSR = renderSSR || settings.renderSSR 79 | const { cache, useCacheData, clientCacheVar, shouldUseApiCache } = settings 80 | cache.reset() 81 | // collect API requests first 82 | let ssrHtml = settings.renderSSR() 83 | ssrHtml = await exports.feedRequests(context, ssrHtml) 84 | if (useCacheData) { 85 | const cacheJson = cache.dump().filter(({ k: cacheKey }) => { 86 | let config: ReactUseApi.Config 87 | try { 88 | config = JSON.parse(cacheKey) 89 | } catch (e) { 90 | config = {} 91 | } 92 | return shouldUseApiCache(config, cacheKey) !== false 93 | }) 94 | const apiCacheScript = Object.keys(cacheJson) 95 | ? `` 99 | : '' 100 | return isFunction(postProcess) 101 | ? postProcess(ssrHtml, apiCacheScript) 102 | : ssrHtml + apiCacheScript 103 | } 104 | return ssrHtml 105 | } 106 | 107 | export const loadApiCache = ( 108 | context: ReactUseApi.Context = { settings: defaultSettings } 109 | ) => { 110 | const { settings } = context 111 | const { clientCacheVar } = settings 112 | const data = window[clientCacheVar] 113 | if (Array.isArray(data)) { 114 | settings.cache.load(data) 115 | delete window[clientCacheVar] 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import * as Axios from 'axios' 4 | 5 | export = ReactUseApi 6 | export as namespace ReactUseApi 7 | 8 | declare namespace ReactUseApi { 9 | type Settings = typeof import('./common').defaultSettings 10 | type CustomSettings = Partial 11 | type ACTIONS = typeof import('./common').ACTIONS 12 | type InitState = typeof import('./common').initState 13 | type SingleConfig = Axios.AxiosRequestConfig 14 | type MultiConfigs = SingleConfig[] 15 | type Config = SingleConfig | MultiConfigs 16 | type ApiResponse = Axios.AxiosResponse 17 | type SingleData = JsonObject | undefined | any 18 | type Data = SingleData | SingleData[] | undefined | any 19 | type RequestFn = ( 20 | cfg?: ReactUseApi.Config, 21 | keepState?: boolean 22 | ) => Promise 23 | 24 | interface Context { 25 | settings?: Settings 26 | isSSR?: boolean 27 | collection?: SSRCollection 28 | isConfigured?: boolean 29 | renderSSR?: Settings['renderSSR'] 30 | clearCache?: () => void 31 | } 32 | interface CustomContext extends Omit { 33 | settings?: CustomSettings 34 | } 35 | interface SSRCollection { 36 | ssrConfigs: SSRConfigs[] 37 | cacheKeys: Set 38 | } 39 | interface SSRConfigs { 40 | config: Config 41 | cacheKey: string 42 | } 43 | interface ApiProviderProps { 44 | context?: CustomContext 45 | } 46 | interface Options { 47 | watch?: any[] 48 | dependencies?: dependencies 49 | skip?: boolean 50 | useCache?: boolean 51 | $cacheKey?: string 52 | $hasChangedConfig?: boolean 53 | handleData?: (data: Data, newState: State) => any 54 | shouldRequest?: () => boolean | void 55 | } 56 | interface dependencies { 57 | readonly [key: string]: any 58 | } 59 | 60 | interface CacheData { 61 | response?: ApiResponse | ApiResponse[] 62 | error?: Axios.AxiosError 63 | } 64 | interface Action extends CacheData { 65 | type: string 66 | options?: Options 67 | fromCache?: boolean 68 | } 69 | interface State extends InitState, CacheData, JsonObject { 70 | data?: Data 71 | prevData?: Data 72 | prevState?: State 73 | dependencies?: Options['dependencies'] 74 | } 75 | 76 | interface RefData { 77 | id: number 78 | cacheKey: string 79 | state: State 80 | config: Config 81 | refreshFlag: number 82 | isInit: boolean 83 | isRequesting: boolean 84 | hasFed: boolean 85 | timeoutID: number 86 | useCache: boolean 87 | // options: Options // debug only 88 | } 89 | 90 | interface DispatchClusterElement { 91 | id: number 92 | dispatch: React.Dispatch 93 | refData: RefData 94 | } 95 | 96 | interface RECORDS { 97 | [cacheKey: string]: { 98 | dispatches: DispatchClusterElement[] 99 | suspense?: { 100 | promise: Promise 101 | done: boolean 102 | reference: number 103 | } 104 | } & CacheData 105 | } 106 | 107 | type MiddlewareInterrupt = () => void 108 | interface Middleware { 109 | onHandleOptions?: ( 110 | config?: Config, 111 | args?: { options: Options }, 112 | interrupt?: MiddlewareInterrupt 113 | ) => Options 114 | onStart?: ( 115 | config?: Config, 116 | args?: { state: State }, 117 | interrupt?: MiddlewareInterrupt 118 | ) => State 119 | onHandleData?: ( 120 | config?: Config, 121 | args?: { 122 | data: Data 123 | response: DataResponse 124 | }, 125 | interrupt?: MiddlewareInterrupt 126 | ) => Data 127 | onHandleError?: ( 128 | config?: Config, 129 | args?: { error: ErrorResponse }, 130 | interrupt?: MiddlewareInterrupt 131 | ) => any 132 | onSuccess?: ( 133 | config?: Config, 134 | args?: { 135 | data: Data 136 | response: DataResponse 137 | }, 138 | interrupt?: MiddlewareInterrupt 139 | ) => void 140 | onError?: ( 141 | config?: Config, 142 | args?: { 143 | error: ErrorResponse 144 | response: DataResponse 145 | }, 146 | interrupt?: MiddlewareInterrupt 147 | ) => void 148 | onDone: ( 149 | config?: Config, 150 | args?: { 151 | data: Data 152 | error: ErrorResponse 153 | response: DataResponse 154 | }, 155 | interrupt?: MiddlewareInterrupt 156 | ) => void 157 | } 158 | type Middlewares = Middleware[] 159 | 160 | interface JsonObject { 161 | [key: string]: any 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/useApi.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useLayoutEffect, 4 | useReducer, 5 | useMemo, 6 | useContext, 7 | useCallback, 8 | useRef, 9 | } from 'react' 10 | 11 | import { ApiContext } from './context' 12 | import { 13 | initState, 14 | ACTIONS, 15 | axiosAll, 16 | getResponseData, 17 | isObject, 18 | isFunction, 19 | } from './common' 20 | 21 | export function useApi( 22 | config: ReactUseApi.SingleConfig | string, 23 | opt?: ReactUseApi.Options | ReactUseApi.Options['handleData'] 24 | ): [D, ReactUseApi.State, ReactUseApi.RequestFn] 25 | export function useApi( 26 | config: ReactUseApi.MultiConfigs, 27 | opt?: ReactUseApi.Options | ReactUseApi.Options['handleData'] 28 | ): [D, ReactUseApi.State, ReactUseApi.RequestFn] 29 | export function useApi( 30 | config: ReactUseApi.Config | string, 31 | opt?: ReactUseApi.Options | ReactUseApi.Options['handleData'] 32 | ): [D, ReactUseApi.State, ReactUseApi.RequestFn] { 33 | if (typeof config === 'string') { 34 | config = { 35 | url: config, 36 | } 37 | } 38 | const context = useContext(ApiContext) 39 | const { 40 | settings, 41 | settings: { cache, debug, clearLastCacheWhenConfigChanges }, 42 | isSSR, 43 | collection: { ssrConfigs, cacheKeys }, 44 | } = context 45 | const cacheKey = JSON.stringify(config) 46 | const ref = useRef( 47 | useMemo( 48 | () => 49 | ({ 50 | id: Date.now(), 51 | isRequesting: false, 52 | isInit: false, 53 | hasFed: false, 54 | refreshFlag: 1, 55 | cacheKey, 56 | config, 57 | } as ReactUseApi.RefData), 58 | [] 59 | ) 60 | ) 61 | const options = useMemo( 62 | () => handleUseApiOptions(opt, settings, config, cacheKey), 63 | [opt, settings, cacheKey] 64 | ) 65 | const { current: refData } = ref 66 | const isValidConfig = verifyConfig(config) 67 | const hasChangedConfig = refData.cacheKey !== cacheKey 68 | options.$hasChangedConfig = hasChangedConfig 69 | if (hasChangedConfig) { 70 | if (clearLastCacheWhenConfigChanges) { 71 | cache.del(refData.cacheKey) 72 | } 73 | refData.cacheKey = cacheKey 74 | refData.config = config 75 | refData.hasFed = false 76 | } 77 | 78 | // SSR processing 79 | const cacheData: ReactUseApi.CacheData = cache.get(cacheKey) 80 | const { skip, useCache } = options 81 | let defaultState = { ...initState } 82 | 83 | if (!skip && !refData.isInit) { 84 | if (cacheData && !refData.hasFed && (isSSR || useCache !== false)) { 85 | const { response, error } = cacheData 86 | const action = { 87 | type: ACTIONS.REQUEST_END, 88 | response, 89 | error, 90 | options, 91 | } as ReactUseApi.Action 92 | debug && console.log('[ReactUseApi][Feed]', cacheKey) 93 | if (!isSSR) { 94 | action.fromCache = true 95 | refData.hasFed = true 96 | } 97 | defaultState = reducer(defaultState, action) 98 | } else if (isSSR) { 99 | if (!cacheKeys.has(cacheKey)) { 100 | cacheKeys.add(cacheKey) 101 | debug && console.log('[ReactUseApi][Collect]', cacheKey) 102 | } 103 | ssrConfigs.push({ 104 | config, 105 | cacheKey, 106 | }) 107 | } 108 | } 109 | 110 | refData.isInit = true 111 | 112 | const [state, dispatch] = useReducer(reducer, defaultState) 113 | const { shouldRequest, watch } = options 114 | const { loading, data } = state 115 | 116 | const request = useCallback( 117 | async ( 118 | cfg = refData.config as ReactUseApi.Config, 119 | keepState = false, 120 | revalidate = true 121 | ) => { 122 | if (options.skip) { 123 | return null 124 | } 125 | // foolproof 126 | if ( 127 | (cfg as React.MouseEvent)?.target && 128 | (cfg as React.MouseEvent)?.isDefaultPrevented 129 | ) { 130 | cfg = refData.config 131 | } 132 | // update state's cachekey for saving the prevState when requesting (refreshing) 133 | // it's good to set true for pagination 134 | if (keepState) { 135 | state.$cacheKey = cacheKey 136 | } 137 | refData.isRequesting = true 138 | return fetchApi(context, cfg, options, dispatch, revalidate) 139 | }, 140 | [context, cacheKey, options, dispatch, state, refData] 141 | ) 142 | 143 | const shouldFetchApi = useCallback( 144 | (forRerender = false) => { 145 | let shouldRequestResult: boolean 146 | if (isFunction(shouldRequest)) { 147 | shouldRequestResult = !!shouldRequest() as boolean 148 | } 149 | return ( 150 | !skip && 151 | !refData.isRequesting && 152 | ((forRerender 153 | ? shouldRequestResult === true 154 | : // false means skip 155 | shouldRequestResult !== false) || 156 | hasChangedConfig) 157 | ) 158 | }, 159 | [skip, refData, shouldRequest, hasChangedConfig] 160 | ) 161 | 162 | // for each re-rendering 163 | if (shouldFetchApi(true)) { 164 | refData.refreshFlag = Date.now() 165 | } 166 | 167 | if (!loading && refData.isRequesting) { 168 | refData.isRequesting = false 169 | } 170 | 171 | const useIsomorphicLayoutEffect = isSSR ? useEffect : useLayoutEffect 172 | const effect: typeof useIsomorphicLayoutEffect = useCallback( 173 | (callback, ...args) => { 174 | const wrapper = () => { 175 | if (isValidConfig) { 176 | return callback() 177 | } 178 | } 179 | return useIsomorphicLayoutEffect(wrapper, ...args) 180 | }, 181 | [isValidConfig] 182 | ) 183 | effect(() => { 184 | // SSR will never invoke request() due to the following cases: 185 | // 1. There is a cacheData for the cacheKey 186 | // 2. Feeding the data come from the cache (using defaultState) 187 | // 3. Calling API forcibly due to useCache=false 188 | // For non-SSR, cacheData will be undefined if cacheKey has been changed 189 | 190 | if (!isSSR && !refData.hasFed) { 191 | request(undefined, undefined, false) 192 | } 193 | }, [ 194 | cacheKey, 195 | useCache, 196 | refData, 197 | refData.refreshFlag, 198 | ...(Array.isArray(watch) ? watch : []), 199 | ]) 200 | 201 | if (!isValidConfig) { 202 | return [undefined, undefined, undefined] 203 | } 204 | return [data, state, request] 205 | } 206 | 207 | export const reducer = ( 208 | state: ReactUseApi.State, 209 | action: ReactUseApi.Action 210 | ): ReactUseApi.State => { 211 | const { type, options } = action 212 | const cacheKey = options.$cacheKey 213 | const basicState = { 214 | $cacheKey: cacheKey, 215 | } 216 | switch (type) { 217 | case ACTIONS.REQUEST_START: { 218 | return { 219 | // reset the state to initState if the cacheKey is changed on the fly 220 | ...(cacheKey !== state.$cacheKey ? initState : state), 221 | loading: true, 222 | error: undefined, 223 | fromCache: false, 224 | ...basicState, 225 | } 226 | } 227 | case ACTIONS.REQUEST_END: { 228 | const { response, error, fromCache } = action 229 | const { prevState: pre, ...prevState } = state 230 | const { data: prevData } = prevState 231 | const { dependencies, $hasChangedConfig } = options 232 | const newState = { 233 | ...prevState, 234 | prevData, 235 | prevState, 236 | loading: false, 237 | response, 238 | dependencies, 239 | error, 240 | fromCache: !!fromCache, 241 | ...basicState, 242 | } 243 | if ($hasChangedConfig) { 244 | delete newState.prevState 245 | delete newState.prevData 246 | } 247 | newState.data = error ? undefined : getResponseData(options, newState) 248 | return newState 249 | } 250 | default: 251 | return state 252 | } 253 | } 254 | 255 | export const fetchApi = async ( 256 | context: ReactUseApi.Context, 257 | config: ReactUseApi.Config, 258 | options: ReactUseApi.Options, 259 | dispatch: React.Dispatch, 260 | revalidate = false 261 | ) => { 262 | const { 263 | settings: { cache }, 264 | } = context 265 | const { $cacheKey: cacheKey, useCache } = options 266 | const promiseKey = `${cacheKey}::promise` 267 | let { response, error } = {} as ReactUseApi.CacheData 268 | try { 269 | const cacheData = cache.get(cacheKey) 270 | let promise = cache.get(promiseKey) 271 | response = cacheData?.response 272 | error = cacheData?.error 273 | let fromCache = !revalidate 274 | if (revalidate) { 275 | dispatch({ type: ACTIONS.REQUEST_START, options }) 276 | response = await axiosAll(context, config) 277 | } else if (useCache !== false && promise) { 278 | dispatch({ type: ACTIONS.REQUEST_START, options }) 279 | response = await promise 280 | } else if (useCache !== false && (response || error)) { 281 | // skip ACTIONS.REQUEST_START 282 | } else if (useCache) { 283 | dispatch({ type: ACTIONS.REQUEST_START, options }) 284 | promise = axiosAll(context, config) 285 | // save promise for next coming hooks 286 | cache.set(promiseKey, promise) 287 | response = await promise 288 | } else { 289 | fromCache = false 290 | dispatch({ type: ACTIONS.REQUEST_START, options }) 291 | response = await axiosAll(context, config) 292 | } 293 | dispatch({ type: ACTIONS.REQUEST_END, response, error, options, fromCache }) 294 | } catch (err) { 295 | error = err 296 | dispatch({ 297 | type: ACTIONS.REQUEST_END, 298 | error, 299 | options, 300 | }) 301 | } finally { 302 | // save the data if there is no cache data come from server 303 | if (useCache) { 304 | if (!cache.has(cacheKey)) { 305 | cache.set(cacheKey, { 306 | response, 307 | error, 308 | }) 309 | } 310 | if (cache.has(promiseKey)) { 311 | cache.del(promiseKey) 312 | } 313 | } 314 | } 315 | } 316 | 317 | export const handleUseApiOptions = ( 318 | opt: 319 | | ReactUseApi.Options 320 | | ReactUseApi.Options['handleData'] 321 | | ReactUseApi.Options['watch'], 322 | settings: ReactUseApi.Settings, 323 | config: ReactUseApi.Config | string, 324 | cacheKey: string 325 | ) => { 326 | const options = isObject(opt) 327 | ? ({ ...opt } as ReactUseApi.Options) 328 | : ({ 329 | watch: Array.isArray(opt) ? opt : [], 330 | handleData: isFunction(opt) ? opt : undefined, 331 | } as ReactUseApi.Options) 332 | 333 | options.$cacheKey = cacheKey 334 | const { alwaysUseCache, shouldUseApiCache } = settings 335 | const globalUseCache = 336 | shouldUseApiCache(config as ReactUseApi.Config, cacheKey) !== false 337 | if (alwaysUseCache) { 338 | options.useCache = true 339 | } else if (globalUseCache === false) { 340 | options.useCache = false 341 | } 342 | return options 343 | } 344 | 345 | export const verifyConfig = (config: ReactUseApi.Config) => { 346 | return isObject(config) 347 | ? !!(config as ReactUseApi.SingleConfig).url 348 | : Array.isArray(config) 349 | ? config.every((each) => !!each.url) 350 | : false 351 | } 352 | 353 | export default useApi 354 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "outDir": "build", 6 | "allowJs": false, 7 | "declaration": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "commonjs", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "noImplicitAny": false, 16 | "jsx": "react", 17 | "baseUrl": ".", 18 | "newLine": "lf", 19 | "experimentalDecorators": true, 20 | "emitDecoratorMetadata": true, 21 | "sourceMap": true, 22 | "typeRoots": ["src/typings.d.ts", "node_modules/@types"] 23 | }, 24 | "include": ["src"], 25 | "exclude": ["node_modules", "**/__tests__/*"] 26 | } 27 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], 3 | "rules": { 4 | "quotemark": [ 5 | true, 6 | "single", 7 | "jsx-double", 8 | "avoid-escape", 9 | "avoid-template" 10 | ], 11 | "semicolon": [true, "never"], 12 | "ordered-imports": false, 13 | "object-literal-sort-keys": false, 14 | "object-literal-key-quotes": [true, "as-needed"], 15 | "member-access": [true, "no-public"], 16 | "max-classes-per-file": false, 17 | "no-console": [false, "log", "error", "trace"], 18 | "no-implicit-dependencies": [false, "dev"], 19 | "no-empty-interface": false, 20 | "no-bitwise": false, 21 | "jsx-boolean-value": false, 22 | "jsx-no-lambda": false, 23 | "no-submodule-imports": false, 24 | "interface-name": [false, "never-prefix"], 25 | "prefer-const": [true, { "destructuring": "all" }], 26 | "no-unused-expression": false 27 | }, 28 | "linterOptions": { 29 | "include": ["src/**/*"], 30 | "exclude": [ 31 | "node_modules", 32 | "build", 33 | "scripts", 34 | "acceptance-tests", 35 | "webpack", 36 | "jest", 37 | "**/__tests__/*", 38 | "src/typings.d.ts" 39 | ] 40 | } 41 | } 42 | --------------------------------------------------------------------------------