├── .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 | [](https://www.npmjs.com/package/react-use-api)
12 | [](https://travis-ci.org/RyanRoll/react-use-api)
13 | [](https://coveralls.io/github/RyanRoll/react-use-api?branch=master)
14 | 
15 | 
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 | Reload
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 | setPage(++page)}>Next
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 | Refresh // 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 && Load More }
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 |
--------------------------------------------------------------------------------