├── .babelrc.js ├── .eslintignore ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── notify-release.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── commitlint.config.js ├── jest.config.js ├── package.json ├── src ├── index.d.ts └── index.js ├── test-d └── index.test-d.ts ├── test ├── .eslintrc ├── axios.d.ts ├── index.test.jsx ├── index.test.ssr.jsx └── testing-library__react-hooks.d.ts └── tsconfig.json /.babelrc.js: -------------------------------------------------------------------------------- 1 | const { NODE_ENV, BABEL_ENV } = process.env 2 | const cjs = NODE_ENV === 'test' || BABEL_ENV === 'commonjs' 3 | const loose = true 4 | 5 | module.exports = { 6 | presets: [['@babel/preset-env', { loose, modules: false }]], 7 | plugins: [ 8 | ['@babel/plugin-transform-object-rest-spread', { loose }], 9 | cjs && ['@babel/transform-modules-commonjs', { loose }], 10 | ['@babel/transform-runtime', { useESModules: !cjs }] 11 | ].filter(Boolean), 12 | env: { 13 | test: { 14 | presets: ['@babel/preset-env', '@babel/preset-react'] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | cjs 2 | coverage 3 | es 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:import/recommended", 6 | "plugin:prettier/recommended", 7 | "plugin:react-hooks/recommended" 8 | ], 9 | "parserOptions": { 10 | "ecmaVersion": "latest" 11 | }, 12 | "env": { 13 | "browser": true, 14 | "node": true 15 | }, 16 | "globals": { 17 | "Promise": true 18 | }, 19 | "rules": { 20 | "valid-jsdoc": 2 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: '/' 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version-file: '.nvmrc' 16 | - run: npm i 17 | - run: npm run lint 18 | - run: npm test -- --coverage 19 | - run: bash <(curl -s https://codecov.io/bash) 20 | 21 | automerge: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | permissions: 25 | pull-requests: write 26 | contents: write 27 | steps: 28 | - uses: fastify/github-action-merge-dependabot@v3 29 | with: 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/notify-release.yml: -------------------------------------------------------------------------------- 1 | name: notify-release 2 | on: 3 | workflow_dispatch: 4 | release: 5 | types: [published] 6 | issues: 7 | types: [closed] 8 | schedule: 9 | - cron: '30 8 * * *' 10 | jobs: 11 | setup: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | issues: write 15 | contents: read 16 | steps: 17 | - name: Notify release 18 | uses: nearform-actions/github-action-notify-release@v1 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | semver: 7 | description: "The semver to use" 8 | required: true 9 | default: "patch" 10 | type: choice 11 | options: 12 | - patch 13 | - minor 14 | - major 15 | tag: 16 | description: "The npm tag" 17 | required: false 18 | default: "latest" 19 | commit-message: 20 | description: "The commit message template" 21 | required: false 22 | default: "chore(release): {version}" 23 | pull_request: 24 | types: [closed] 25 | 26 | jobs: 27 | release: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: nearform/optic-release-automation-action@v4 31 | with: 32 | github-token: ${{ secrets.github_token }} 33 | npm-token: ${{ secrets.NPM_TOKEN }} 34 | commit-message: ${{ github.event.inputs.commit-message }} 35 | semver: ${{ github.event.inputs.semver }} 36 | npm-tag: ${{ github.event.inputs.tag }} 37 | build-command: npm i 38 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '30 1 * * *' 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v9 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | es 2 | cjs 3 | node_modules 4 | coverage 5 | npm-debug.log* 6 | *.test.ts* 7 | *.test.ssr.ts* 8 | package-lock.json 9 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx --no-install lint-staged 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "arrowParens": "avoid", 6 | "endOfLine": "auto" 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | > ⚠ This file is deprecated. Releases after 3.0.0 use GitHub's built-in releases to track changes ⚠ 2 | 3 | # Changelog 4 | 5 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 6 | 7 | ## [3.0.0](https://github.com/simoneb/axios-hooks/compare/v2.7.0...v3.0.0) (2021-10-31) 8 | 9 | 10 | ### ⚠ BREAKING CHANGES 11 | 12 | * requires axios@^0.24.0 13 | 14 | ### Features 15 | 16 | * apply type-safe for AxiosRequestConfig data parameter ([#611](https://github.com/simoneb/axios-hooks/issues/611)) ([eb05b0a](https://github.com/simoneb/axios-hooks/commit/eb05b0a3358585d051e03365a6924a5fb90a71b4)) 17 | 18 | ## [2.7.0](https://github.com/simoneb/axios-hooks/compare/v2.6.3...v2.7.0) (2021-08-29) 19 | 20 | 21 | ### Features 22 | 23 | * skip automatic request cancellation ([#533](https://github.com/simoneb/axios-hooks/issues/533)) ([624ea44](https://github.com/simoneb/axios-hooks/commit/624ea4426a5609c2c928b56be1e99ab06c23ac93)) 24 | 25 | ### [2.6.3](https://github.com/simoneb/axios-hooks/compare/v2.6.2...v2.6.3) (2021-05-17) 26 | 27 | ### [2.6.2](https://github.com/simoneb/axios-hooks/compare/v2.6.1...v2.6.2) (2021-05-13) 28 | 29 | 30 | ### Bug Fixes 31 | 32 | * **deps:** update dependency @babel/runtime to v7.13.17 ([259ea08](https://github.com/simoneb/axios-hooks/commit/259ea087174890284efda69f91b00537e9f9c7ce)) 33 | 34 | ### [2.6.1](https://github.com/simoneb/axios-hooks/compare/v2.6.0...v2.6.1) (2021-03-30) 35 | 36 | ## [2.6.0](https://github.com/simoneb/axios-hooks/compare/v2.5.1...v2.6.0) (2021-03-28) 37 | 38 | 39 | ### Features 40 | 41 | * reset hook state when config changes with outstanding manual fetch ([8b2f166](https://github.com/simoneb/axios-hooks/commit/8b2f166c41c453026fde4dc9eded1c9ee927cc46)) 42 | * update the hook state when cancelled manually ([203541b](https://github.com/simoneb/axios-hooks/commit/203541bb3ad52f2bd95d453ff0ffd3d1663e2794)) 43 | 44 | ### [2.5.1](https://github.com/simoneb/axios-hooks/compare/v2.5.0...v2.5.1) (2021-03-28) 45 | 46 | 47 | ### Features 48 | 49 | * **typings:** expose return type of useAxios ([3273ba1](https://github.com/simoneb/axios-hooks/commit/3273ba1fba2f7417bdc7844e77fe47ecd6fe2761)) 50 | 51 | ## [2.5.0](https://github.com/simoneb/axios-hooks/compare/v2.5.0-0...v2.5.0) (2021-03-12) 52 | 53 | ## [2.5.0-0](https://github.com/simoneb/axios-hooks/compare/v2.4.1...v2.5.0-0) (2021-03-06) 54 | 55 | 56 | ### Features 57 | 58 | * no config serialization ([15fe158](https://github.com/simoneb/axios-hooks/commit/15fe1588448fc58b0b5b83815cc3a12812a466a2)) 59 | 60 | 61 | ### Bug Fixes 62 | 63 | * tests ([4176d94](https://github.com/simoneb/axios-hooks/commit/4176d9489085febda797dabddb104141197a901c)) 64 | 65 | ### [2.4.1](https://github.com/simoneb/axios-hooks/compare/v2.4.0...v2.4.1) (2021-03-06) 66 | 67 | 68 | ### Bug Fixes 69 | 70 | * regression in config serialization ([c5e8645](https://github.com/simoneb/axios-hooks/commit/c5e86450811b64e1babdd2cff97ad463b5ee83b2)) 71 | 72 | ## [2.4.0](https://github.com/simoneb/axios-hooks/compare/v2.4.0-0...v2.4.0) (2021-03-05) 73 | 74 | 75 | ### Features 76 | 77 | * remove config serialization ([06a8197](https://github.com/simoneb/axios-hooks/commit/06a819728d7631c0ac26e774b447885597ba6c2f)) 78 | 79 | ## [2.4.0-0](https://github.com/simoneb/axios-hooks/compare/v2.3.0...v2.4.0-0) (2021-02-12) 80 | 81 | 82 | ### Features 83 | 84 | * add two more tests of the cancel method ([9612292](https://github.com/simoneb/axios-hooks/commit/961229226b05ab8d55a01e35b0adc3424f7415b8)) 85 | * return the cancelOutstandingRequest method in the index after the refetch method ([c6346c0](https://github.com/simoneb/axios-hooks/commit/c6346c0bb0d6184060d9ccb97f21082b9344dc42)) 86 | 87 | ## [2.3.0](https://github.com/simoneb/axios-hooks/compare/v2.2.0...v2.3.0) (2021-01-14) 88 | 89 | 90 | ### Features 91 | 92 | * default options ([b218e68](https://github.com/simoneb/axios-hooks/commit/b218e689ae3c0e69f0ff3645785bb2ad968f2696)) 93 | 94 | ## [2.2.0](https://github.com/simoneb/axios-hooks/compare/v2.1.0...v2.2.0) (2020-11-18) 95 | 96 | 97 | ### Bug Fixes 98 | 99 | * **deps:** update dependency lru-cache to v6 ([e589be6](https://github.com/simoneb/axios-hooks/commit/e589be6100130269c4dcdc202288018d1a45cebb)) 100 | * **typings:** improve typing of the error and response properties on ResponseValues ([4479a90](https://github.com/simoneb/axios-hooks/commit/4479a906a8db61511a06f5836c6cb9a5c7729e80)) 101 | 102 | ## [2.1.0](https://github.com/simoneb/axios-hooks/compare/v2.0.0...v2.1.0) (2020-07-04) 103 | 104 | 105 | ### Features 106 | 107 | * **269:** options re-evaluation ([9218707](https://github.com/simoneb/axios-hooks/commit/9218707871750c472420bf1ae1a570969b96e3bf)), closes [#269](https://github.com/simoneb/axios-hooks/issues/269) 108 | 109 | ## [2.0.0](https://github.com/simoneb/axios-hooks/compare/v1.11.0...v2.0.0) (2020-06-24) 110 | 111 | 112 | ### ⚠ BREAKING CHANGES 113 | 114 | * This release introduces a fundamental change in the caching mechanism. 115 | 116 | The main difference is that requests that don't use the cache 117 | will store the response in the cache anyway, making the behavior 118 | of the library more intuitive and predictable. 119 | 120 | In other words, `{ useCache: false }` will only skip _reading_ from the cache, 121 | but it will write the response to the cache in any case. 122 | 123 | The docs contain a caching example providing a full overview of 124 | how the new caching behavior works. 125 | 126 | A potential side effect of the new behavior, which we tried mitigating, 127 | is that the `refetch` function returned by the hook, which was always 128 | skipping the cache previously, now stores the response in cache. 129 | Because of this, it must generate a key for the cache, which is created 130 | based on the configuration provided as the first argument to the `refetch` 131 | function itself. 132 | 133 | Because the `refetch` function is often provided directly to DOM event handlers: 134 | 135 | ``` 136 | 48 |
{JSON.stringify(data, null, 2)}
49 | 50 | ) 51 | } 52 | ``` 53 | 54 | ## Documentation 55 | 56 | ### API 57 | 58 | - [useAxios](#useaxiosurlconfig-options) 59 | - [configure](#configure-cache-axios-defaultoptions-) 60 | - [serializeCache](#serializeCache) 61 | - [loadCache](#loadcachecache) 62 | - [makeUseAxios](#makeuseaxios-cache-axios-defaultoptions-) 63 | 64 | ### Examples 65 | 66 | - [Quick start](https://codesandbox.io/s/2oxrlq8rjr) 67 | - [Manual request](https://codesandbox.io/s/axioshooks-manual-request-bq9w4) 68 | - [Error handling](https://codesandbox.io/s/axios-hooks-error-handling-gvi41) 69 | - [Authentication and token refresh](https://codesandbox.io/s/axios-hooks-authentication-zyeyh) 70 | - [Caching](https://codesandbox.io/s/axios-hooks-caching-nm62v) 71 | - [Using makeUseAxios](https://codesandbox.io/s/axios-hooks-makeuseaxios-kfuym) 72 | - [Configuration](https://codesandbox.io/s/oqvxw6mpyq) 73 | - [Pagination](https://codesandbox.io/s/axios-hooks-pagination-1wk3u) 74 | - [Infinite scrolling](https://codesandbox.io/s/axios-hooks-infinite-scrolling-42nw6) 75 | - [Request chaining](https://codesandbox.io/s/axios-hooks-request-chaining-wn12l) 76 | - [Options change detection](https://codesandbox.io/s/axios-hooks-options-change-v23tl) 77 | - [react-native](https://snack.expo.io/@simoneb/axios-hooks-react-native) 78 | - [With react-sortable-hoc](https://codesandbox.io/s/axios-hooks-react-sortable-hoc-eo3oy) 79 | - [With react-router](https://codesandbox.io/s/axios-hooks-react-router-26iwm) 80 | 81 | ### Guides 82 | 83 | - [Refresh Behavior](#refresh-behavior) 84 | - [Configuration](#configuration) 85 | - [Manual Requests](#manual-requests) 86 | - [Manual Cancellation](#manual-cancellation) 87 | - [Server Side Rendering](#server-side-rendering) 88 | - [Multiple Hook Instances](#multiple-hook-instances) 89 | 90 | ## API 91 | 92 | The package exports one default export and named exports: 93 | 94 | ```js 95 | import useAxios, { 96 | configure, 97 | loadCache, 98 | serializeCache, 99 | makeUseAxios 100 | } from 'axios-hooks' 101 | ``` 102 | 103 | ### useAxios(url|config, options) 104 | 105 | The main React hook to execute HTTP requests. 106 | 107 | - `url|config` - The request URL or [config](https://github.com/axios/axios#request-config) object, the same argument accepted by `axios`. 108 | - `options` - An options object. 109 | - `manual` ( `false` ) - If true, the request is not executed immediately. Useful for non-GET requests that should not be executed when the component renders. Use the `execute` function returned when invoking the hook to execute the request manually. 110 | - `useCache` ( `true` ) - Allows caching to be enabled/disabled for the hook. It doesn't affect the `execute` function returned by the hook. 111 | - `ssr` ( `true` ) - Enables or disables SSR support 112 | - `autoCancel` ( `true` ) - Enables or disables automatic cancellation of pending requests whether it be 113 | from the automatic hook request or from the `manual` execute method 114 | 115 | > [!IMPORTANT] 116 | > Default caching behavior can interfere with test isolation. Read the [testing](#testing) section for more information. 117 | 118 | **Returns** 119 | 120 | `[{ data, loading, error, response }, execute, manualCancel]` 121 | 122 | - `data` - The [success response](https://github.com/axios/axios#response-schema) data property (for convenient access). 123 | - `loading` - True if the request is in progress, otherwise False. 124 | - `error` - The [error](https://github.com/axios/axios#handling-errors) value 125 | - `response` - The whole [success response](https://github.com/axios/axios#response-schema) object. 126 | 127 | - `execute([config[, options]])` - A function to execute the request manually, bypassing the cache by default. 128 | 129 | - `config` - Same `config` object as `axios`, which is _shallow-merged_ with the config object provided when invoking the hook. Useful to provide arguments to non-GET requests. 130 | - `options` - An options object. 131 | - `useCache` ( `false` ) - Allows caching to be enabled/disabled for this "execute" function. 132 | 133 | **Returns** 134 | 135 | A promise containing the response. If the request is unsuccessful, the promise rejects and the rejection must be handled manually. 136 | 137 | - `manualCancel()` - A function to cancel outstanding requests manually. 138 | 139 | ### configure({ cache, axios, defaultOptions }) 140 | 141 | Allows to provide custom instances of cache and axios and to override the default options. 142 | 143 | - `cache` An instance of [lru-cache](https://github.com/isaacs/node-lru-cache), or `false` to disable the cache 144 | - `axios` An instance of [axios](https://github.com/axios/axios#creating-an-instance) 145 | - `defaultOptions` An object overriding the default Hook options. It will be merged with the default options. 146 | 147 | ### serializeCache() 148 | 149 | Dumps the request-response cache, to use in server side rendering scenarios. 150 | 151 | **Returns** 152 | 153 | `Promise` A serializable representation of the request-response cache ready to be used by `loadCache` 154 | 155 | ### loadCache(cache) 156 | 157 | Populates the cache with serialized data generated by `serializeCache`. 158 | 159 | - `cache` The serializable representation of the request-response cache generated by `serializeCache` 160 | 161 | ### makeUseAxios({ cache, axios, defaultOptions }) 162 | 163 | Creates an instance of the `useAxios` hook configured with the supplied cache, axios instance and default options. 164 | 165 | - `cache` An instance of [lru-cache](https://github.com/isaacs/node-lru-cache), or `false` to disable the cache 166 | - `axios` An instance of [axios](https://github.com/axios/axios#creating-an-instance) 167 | - `defaultOptions` An object overriding the default Hook options. It will be merged with the default options. 168 | 169 | **Returns** 170 | 171 | An instance of `useAxios` React Hook which will always use the provided cache and axios instance. 172 | 173 | The returned value, besides being a function that can be used as a React Hook, also contains the properties: 174 | 175 | - `resetConfigure` 176 | - `configure` 177 | - `loadCache` 178 | - `serializeCache` 179 | 180 | which are the same as the package's named exports but limited to the `useAxios` instance returned by `makeUseAxios`. 181 | 182 | ## Refresh Behavior 183 | 184 | The arguments provided to `useAxios(config[,options])` are watched for changes and compared using deep object comparison. 185 | 186 | When they change, if the configuration allows a request to be fired (e.g. `manual:false`), any pending request is canceled and a new request is triggered, to avoid automatic cancellation you should use `autoCancel:false` option 187 | 188 | Because of this, it's important to make sure that the arguments to `useAxios` preserve deep equality across component renders. This is often the case unless functions (e.g. axios transformers) are provided to a configuration object. In that case, those functions need to be memoized or they will trigger a request execution at each render, leading to an infinite loop. 189 | 190 | ## Configuration 191 | 192 | Unless provided via the `configure` function, `axios-hooks` uses as defaults: 193 | 194 | - `axios` - the default `axios` package export 195 | - `cache` - a new instance of the default `lru-cache` package export, with no arguments 196 | - `defaultOptions` - `{ manual: false, useCache: true, ssr: true, autoCancel: true }` 197 | 198 | These defaults may not suit your needs, for example: 199 | 200 | - you may want a common base url for axios requests 201 | - the default (Infinite) cache size may not be a sensible default 202 | - you want to disable caching altogether 203 | 204 | In such cases you can use the `configure` function to provide your custom implementation of both. 205 | 206 | > When `configure` is used, it should be invoked once before any usages of the `useAxios` hook 207 | 208 | ### Example 209 | 210 | [![Edit axios-hooks configuration example](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/oqvxw6mpyq) 211 | 212 | ```js 213 | import { configure } from 'axios-hooks' 214 | import LRU from 'lru-cache' 215 | import Axios from 'axios' 216 | 217 | const axios = Axios.create({ 218 | baseURL: 'https://reqres.in/api' 219 | }) 220 | 221 | const cache = new LRU({ max: 10 }) 222 | 223 | configure({ axios, cache }) 224 | ``` 225 | 226 | ## Manual Requests 227 | 228 | On the client, requests are executed when the component renders using a React `useEffect` hook. 229 | 230 | This may be undesirable, as in the case of non-GET requests. By using the `manual` option you can skip the automatic execution of requests and use the return value of the hook to execute them manually, optionally providing configuration overrides to `axios`. 231 | 232 | ### Example 233 | 234 | In the example below we use the `useAxios` hook twice. Once to load the data when the component renders, and once to submit data updates via a `PUT` request configured via the `manual` option. 235 | 236 | [![Edit axios-hooks Manual Request](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/axioshooks-manual-request-bq9w4?fontsize=14) 237 | 238 | ```js 239 | import useAxios from 'axios-hooks' 240 | 241 | function App() { 242 | const [{ data: getData, loading: getLoading, error: getError }] = useAxios( 243 | 'https://reqres.in/api/users/1' 244 | ) 245 | 246 | const [{ data: putData, loading: putLoading, error: putError }, executePut] = 247 | useAxios( 248 | { 249 | url: 'https://reqres.in/api/users/1', 250 | method: 'PUT' 251 | }, 252 | { manual: true } 253 | ) 254 | 255 | function updateData() { 256 | executePut({ 257 | data: { 258 | ...getData, 259 | updatedAt: new Date().toISOString() 260 | } 261 | }) 262 | } 263 | 264 | if (getLoading || putLoading) return

Loading...

265 | if (getError || putError) return

Error!

266 | 267 | return ( 268 |
269 | 270 |
{JSON.stringify(putData || getData, null, 2)}
271 |
272 | ) 273 | } 274 | ``` 275 | 276 | ## Manual Cancellation 277 | 278 | The cancellation method can be used to cancel an outstanding request whether it be 279 | from the automatic hook request or from the `manual` execute method. 280 | 281 | ### Example 282 | 283 | In the example below we use the `useAxios` hook with its automatic and manual requests. 284 | We can call the cancellation programmatically or via controls. 285 | 286 | ```js 287 | function App() { 288 | const [pagination, setPagination] = useState({}) 289 | const [{ data, loading }, refetch, cancelRequest] = useAxios({ 290 | url: '/users?delay=5', 291 | params: { ...pagination } 292 | }) 293 | 294 | const handleFetch = () => { 295 | setPagination({ per_page: 2, page: 2 }) 296 | } 297 | 298 | const externalRefetch = async () => { 299 | try { 300 | await refetch() 301 | } catch (e) { 302 | // Handle cancellation 303 | } 304 | } 305 | 306 | return ( 307 |
308 | 309 | 310 | 313 | {loading &&

...loading

} 314 |
{JSON.stringify(data, null, 2)}
315 |
316 | ) 317 | } 318 | ``` 319 | 320 | ## Server Side Rendering 321 | 322 | `axios-hooks` seamlessly supports server side rendering scenarios, by preloading data on the server and providing the data to the client, so that the client doesn't need to reload it. 323 | 324 | ### How it works 325 | 326 | 1. the React component tree is rendered on the server 327 | 2. `useAxios` HTTP requests are executed on the server 328 | 3. the server code awaits `serializeCache()` in order to obtain a serializable representation of the request-response cache 329 | 4. the server injects a JSON-serialized version of the cache in a `window` global variable 330 | 5. the client hydrates the cache from the global variable before rendering the application using `loadCache` 331 | 332 | ### Example 333 | 334 | [![Edit axios-hooks SSR example](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/v83l3mjq57) 335 | 336 | ```html 337 | 338 | 339 | 342 | ``` 343 | 344 | ```js 345 | // server code for the server side rendering handler 346 | 347 | import { serializeCache } from 'axios-hooks' 348 | 349 | router.use(async (req, res) => { 350 | const index = fs.readFileSync(`${publicFolder}/index.html`, 'utf8') 351 | const html = ReactDOM.renderToString() 352 | 353 | // wait for axios-hooks HTTP requests to complete 354 | const cache = await serializeCache() 355 | 356 | res.send( 357 | index 358 | .replace('{{{html}}}', html) 359 | .replace('{{{cache}}}', JSON.stringify(cache).replace(/, document.getElementById('root')) 374 | ``` 375 | 376 | ## Multiple Hook Instances 377 | 378 | Sometimes it is necessary to communicate with different APIs or use different caching strategies for different HTTP interactions. 379 | 380 | [`makeUseAxios`](#makeuseaxios-cache-axios) allows to create multiple instances of the `useAxios` React Hook which can be configured and managed independently. 381 | 382 | In other words, `makeUseAxios` is a factory of `useAxios`, which returns a React Hook configured against the provided `axios` or `cache` instances. 383 | 384 | > This feature can also be used to create a single pre configured React Hook instance as an alternative to the global `configure` feature 385 | 386 | ### Example 387 | 388 | [![Edit axios-hooks makeUseAxios](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/axios-hooks-quick-start-kfuym) 389 | 390 | ```js 391 | import axios from 'axios' 392 | import { makeUseAxios } from 'axios-hooks' 393 | 394 | const useAxios = makeUseAxios({ 395 | axios: axios.create({ baseURL: 'https://reqres.in/api' }) 396 | }) 397 | 398 | function App() { 399 | const [{ data, loading, error }, refetch] = useAxios('/users?delay=1') 400 | 401 | if (loading) return

Loading...

402 | if (error) return

Error!

403 | 404 | return ( 405 |
406 | 407 |
{JSON.stringify(data, null, 2)}
408 |
409 | ) 410 | } 411 | ``` 412 | 413 | ## Testing 414 | 415 | Testing components that make use of the `useAxios` hook are susceptible to test isolation leakage because of default caching behavior. The following snippets can be used to disable caching while testing: 416 | 417 | ### react-testing-library 418 | 419 | ```js 420 | beforeAll(() => { 421 | useAxios.configure({ cache: false }) 422 | }) 423 | ``` 424 | 425 | ## Promises 426 | 427 | axios-hooks depends on a native ES6 Promise implementation to be [supported](http://caniuse.com/promises). 428 | If your environment doesn't support ES6 Promises, you can [polyfill](https://github.com/jakearchibald/es6-promise). 429 | 430 | ## Credits 431 | 432 | `axios-hooks` is heavily inspired by [graphql-hooks](https://github.com/nearform/graphql-hooks), 433 | developed by the awesome people at [NearForm](https://github.com/nearform). 434 | 435 | ## License 436 | 437 | MIT 438 | 439 | [axios]: https://github.com/axios/axios 440 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'] 3 | } 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const commonOptions = { 2 | resetMocks: true, 3 | coverageDirectory: 'coverage' 4 | } 5 | 6 | const projects = [ 7 | { 8 | displayName: 'js', 9 | testMatch: ['**/?(*.)+(spec|test).js?(x)'], 10 | testEnvironment: 'jsdom' 11 | }, 12 | { 13 | displayName: 'ts', 14 | testMatch: ['**/?(*.)+(spec|test).ts?(x)'], 15 | preset: 'ts-jest/presets/js-with-ts', 16 | testEnvironment: 'jsdom' 17 | }, 18 | { 19 | displayName: 'ssr-js', 20 | testMatch: ['**/?(*.)+(spec|test).ssr.js?(x)'], 21 | testEnvironment: 'node' 22 | }, 23 | { 24 | displayName: 'ssr-ts', 25 | testMatch: ['**/?(*.)+(spec|test).ssr.ts?(x)'], 26 | preset: 'ts-jest/presets/js-with-ts', 27 | testEnvironment: 'node' 28 | } 29 | ] 30 | 31 | module.exports = { 32 | projects: projects.map(p => ({ ...p, ...commonOptions })) 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "axios-hooks", 3 | "version": "5.1.1", 4 | "description": "axios-hooks", 5 | "keywords": [ 6 | "axios", 7 | "react", 8 | "hooks" 9 | ], 10 | "license": "MIT", 11 | "author": "Simone Busoli ", 12 | "homepage": "https://github.com/simoneb/axios-hooks", 13 | "repository": "simoneb/axios-hooks", 14 | "bugs": "https://github.com/simoneb/axios-hooks/issues", 15 | "main": "cjs/index.js", 16 | "module": "es/index.js", 17 | "types": "src/index.d.ts", 18 | "files": [ 19 | "cjs/", 20 | "es/", 21 | "src/" 22 | ], 23 | "scripts": { 24 | "build:commonjs": "cross-env BABEL_ENV=commonjs babel src --out-dir cjs", 25 | "build:es": "babel src --out-dir es", 26 | "build": "run-p build:*", 27 | "clean": "rimraf cjs es", 28 | "format": "prettier --write \"{src,test}/**/*.{js?(x),md,ts?(x)}\"", 29 | "lint": "eslint . --ext .js,.jsx", 30 | "prepare": "npm run clean && npm run build && husky install", 31 | "pretest": "shx cp ./test/index.test.jsx ./test/index.test.tsx && shx cp ./test/index.test.ssr.jsx ./test/index.test.ssr.tsx", 32 | "test": "tsd && jest --no-cache" 33 | }, 34 | "dependencies": { 35 | "@babel/runtime": "7.27.1", 36 | "dequal": "2.0.3", 37 | "lru-cache": "^11.0.0" 38 | }, 39 | "peerDependencies": { 40 | "axios": ">=1.0.0", 41 | "react": "^16.8.0-0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 42 | }, 43 | "devDependencies": { 44 | "@babel/cli": "7.27.1", 45 | "@babel/core": "7.26.10", 46 | "@babel/plugin-transform-runtime": "7.27.1", 47 | "@babel/preset-env": "7.27.1", 48 | "@babel/preset-react": "7.26.3", 49 | "@commitlint/cli": "19.8.0", 50 | "@commitlint/config-conventional": "19.8.0", 51 | "@testing-library/react": "12.1.5", 52 | "@testing-library/react-hooks": "7.0.2", 53 | "@types/jest": "29.5.14", 54 | "@types/node": "22.15.3", 55 | "@types/react": "18.2.20", 56 | "@types/react-dom": "18.2.7", 57 | "axios": "1.9.0", 58 | "cross-env": "7.0.3", 59 | "eslint": "8.57.0", 60 | "eslint-config-prettier": "10.1.2", 61 | "eslint-plugin-import": "2.31.0", 62 | "eslint-plugin-prettier": "5.4.0", 63 | "eslint-plugin-react": "7.37.5", 64 | "eslint-plugin-react-hooks": "5.2.0", 65 | "husky": "^9.0.3", 66 | "jest": "29.7.0", 67 | "jest-environment-jsdom": "^29.6.4", 68 | "lint-staged": "15.5.1", 69 | "npm-run-all": "4.1.5", 70 | "prettier": "3.5.3", 71 | "react": "17.0.2", 72 | "react-dom": "17.0.2", 73 | "rimraf": "6.0.1", 74 | "shx": "0.4.0", 75 | "ts-jest": "29.3.2", 76 | "tsd": "^0.32.0", 77 | "typescript": "5.8.3" 78 | }, 79 | "lint-staged": { 80 | "{src,test}/**/*.{js?(x),md}": [ 81 | "eslint --fix" 82 | ] 83 | }, 84 | "tsd": { 85 | "directory": "test-d" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AxiosRequestConfig, 3 | AxiosError, 4 | AxiosPromise, 5 | AxiosStatic, 6 | AxiosInstance, 7 | AxiosResponse 8 | } from 'axios' 9 | import { LRUCache } from 'lru-cache' 10 | 11 | export interface ResponseValues { 12 | data?: TResponse 13 | loading: boolean 14 | error: AxiosError | null 15 | response?: AxiosResponse 16 | } 17 | 18 | export interface Options { 19 | manual?: boolean 20 | useCache?: boolean 21 | ssr?: boolean 22 | autoCancel?: boolean 23 | } 24 | 25 | export interface RefetchOptions { 26 | useCache?: boolean 27 | } 28 | 29 | export interface ConfigureOptions { 30 | axios?: AxiosInstance | AxiosStatic | any 31 | cache?: LRUCache | false 32 | defaultOptions?: Options 33 | } 34 | 35 | export interface RefetchFunction { 36 | ( 37 | config?: AxiosRequestConfig | string, 38 | options?: RefetchOptions 39 | ): AxiosPromise 40 | (e: Event): AxiosPromise 41 | } 42 | 43 | export type UseAxiosResult = [ 44 | ResponseValues, 45 | RefetchFunction, 46 | () => void 47 | ] 48 | 49 | export interface UseAxios { 50 | ( 51 | config: AxiosRequestConfig | string, 52 | options?: Options 53 | ): UseAxiosResult 54 | 55 | loadCache(data: any[]): void 56 | serializeCache(): Promise 57 | 58 | configure(options: ConfigureOptions): void 59 | resetConfigure(): void 60 | clearCache(): void 61 | 62 | // private 63 | __ssrPromises: Promise[] 64 | } 65 | 66 | declare const useAxios: UseAxios 67 | 68 | export default useAxios 69 | 70 | export function loadCache(data: any[]): void 71 | export function serializeCache(): Promise 72 | export function clearCache(): void 73 | 74 | export function configure(options: ConfigureOptions): void 75 | export function resetConfigure(): void 76 | 77 | // private 78 | export const __ssrPromises: Promise[] 79 | 80 | export function makeUseAxios(options?: ConfigureOptions): UseAxios 81 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import StaticAxios, { isCancel } from 'axios' 3 | import { LRUCache } from 'lru-cache' 4 | import { dequal as deepEqual } from 'dequal/lite' 5 | 6 | const actions = { 7 | REQUEST_START: 'REQUEST_START', 8 | REQUEST_END: 'REQUEST_END' 9 | } 10 | 11 | const DEFAULT_OPTIONS = { 12 | manual: false, 13 | useCache: true, 14 | ssr: true, 15 | autoCancel: true 16 | } 17 | 18 | const useAxios = makeUseAxios() 19 | 20 | const { 21 | __ssrPromises, 22 | resetConfigure, 23 | configure, 24 | loadCache, 25 | serializeCache, 26 | clearCache 27 | } = useAxios 28 | 29 | export default useAxios 30 | 31 | export { 32 | __ssrPromises, 33 | resetConfigure, 34 | configure, 35 | loadCache, 36 | serializeCache, 37 | clearCache 38 | } 39 | 40 | function isReactEvent(obj) { 41 | return obj && obj.nativeEvent && obj.nativeEvent instanceof Event 42 | } 43 | 44 | function createCacheKey(config) { 45 | const cleanedConfig = { ...config } 46 | delete cleanedConfig.cancelToken 47 | 48 | return JSON.stringify(cleanedConfig) 49 | } 50 | 51 | function configToObject(config) { 52 | if (typeof config === 'string') { 53 | return { 54 | url: config 55 | } 56 | } 57 | 58 | return Object.assign({}, config) 59 | } 60 | 61 | export function makeUseAxios(configureOptions) { 62 | /** 63 | * @type {import('lru-cache')} 64 | */ 65 | let cache 66 | let axiosInstance 67 | let defaultOptions 68 | 69 | const __ssrPromises = [] 70 | 71 | function resetConfigure() { 72 | cache = new LRUCache({ max: 500 }) 73 | axiosInstance = StaticAxios 74 | defaultOptions = DEFAULT_OPTIONS 75 | } 76 | 77 | function configure(options = {}) { 78 | if (options.axios !== undefined) { 79 | axiosInstance = options.axios 80 | } 81 | 82 | if (options.cache !== undefined) { 83 | cache = options.cache 84 | } 85 | 86 | if (options.defaultOptions !== undefined) { 87 | defaultOptions = { ...DEFAULT_OPTIONS, ...options.defaultOptions } 88 | } 89 | } 90 | 91 | resetConfigure() 92 | configure(configureOptions) 93 | 94 | function loadCache(data) { 95 | cache.load(data) 96 | } 97 | 98 | async function serializeCache() { 99 | const ssrPromisesCopy = [...__ssrPromises] 100 | 101 | __ssrPromises.length = 0 102 | 103 | await Promise.all(ssrPromisesCopy) 104 | 105 | return cache.dump() 106 | } 107 | 108 | function clearCache() { 109 | cache.clear() 110 | } 111 | 112 | return Object.assign(useAxios, { 113 | __ssrPromises, 114 | resetConfigure, 115 | configure, 116 | loadCache, 117 | serializeCache, 118 | clearCache 119 | }) 120 | 121 | function tryStoreInCache(config, response) { 122 | if (!cache) { 123 | return 124 | } 125 | 126 | const cacheKey = createCacheKey(config) 127 | 128 | const responseForCache = { ...response } 129 | delete responseForCache.config 130 | delete responseForCache.request 131 | 132 | cache.set(cacheKey, responseForCache) 133 | } 134 | 135 | function createInitialState(config, options) { 136 | const response = !options.manual && tryGetFromCache(config, options) 137 | 138 | return { 139 | loading: !options.manual && !response, 140 | error: null, 141 | ...(response ? { data: response.data, response } : null) 142 | } 143 | } 144 | 145 | function reducer(state, action) { 146 | switch (action.type) { 147 | case actions.REQUEST_START: 148 | return { 149 | ...state, 150 | loading: true, 151 | error: null 152 | } 153 | case actions.REQUEST_END: 154 | return { 155 | ...state, 156 | loading: false, 157 | // set data and error 158 | ...(action.error ? {} : { data: action.payload.data, error: null }), 159 | // set raw response or error 160 | [action.error ? 'error' : 'response']: action.payload 161 | } 162 | } 163 | } 164 | 165 | function tryGetFromCache(config, options, dispatch) { 166 | if (!cache || !options.useCache) { 167 | return 168 | } 169 | 170 | const cacheKey = createCacheKey(config) 171 | const response = cache.get(cacheKey) 172 | 173 | if (response && dispatch) { 174 | dispatch({ type: actions.REQUEST_END, payload: response }) 175 | } 176 | 177 | return response 178 | } 179 | 180 | async function executeRequest(config, dispatch) { 181 | try { 182 | dispatch({ type: actions.REQUEST_START }) 183 | 184 | const response = await axiosInstance(config) 185 | 186 | tryStoreInCache(config, response) 187 | 188 | dispatch({ type: actions.REQUEST_END, payload: response }) 189 | 190 | return response 191 | } catch (err) { 192 | if (!isCancel(err)) { 193 | dispatch({ type: actions.REQUEST_END, payload: err, error: true }) 194 | } 195 | 196 | throw err 197 | } 198 | } 199 | 200 | async function request(config, options, dispatch) { 201 | return ( 202 | tryGetFromCache(config, options, dispatch) || 203 | executeRequest(config, dispatch) 204 | ) 205 | } 206 | 207 | function useAxios(_config, _options) { 208 | const config = React.useMemo( 209 | () => configToObject(_config), 210 | // eslint-disable-next-line react-hooks/exhaustive-deps 211 | useDeepCompareMemoize(_config) 212 | ) 213 | 214 | const options = React.useMemo( 215 | () => ({ ...defaultOptions, ..._options }), 216 | // eslint-disable-next-line react-hooks/exhaustive-deps 217 | useDeepCompareMemoize(_options) 218 | ) 219 | 220 | const abortControllerRef = React.useRef() 221 | 222 | const [state, dispatch] = React.useReducer( 223 | reducer, 224 | createInitialState(config, options) 225 | ) 226 | 227 | if (typeof window === 'undefined' && options.ssr && !options.manual) { 228 | useAxios.__ssrPromises.push(axiosInstance(config)) 229 | } 230 | 231 | const cancelOutstandingRequest = React.useCallback(() => { 232 | if (abortControllerRef.current) { 233 | abortControllerRef.current.abort() 234 | } 235 | }, []) 236 | 237 | const withAbortSignal = React.useCallback( 238 | config => { 239 | if (options.autoCancel) { 240 | cancelOutstandingRequest() 241 | } 242 | 243 | abortControllerRef.current = new AbortController() 244 | 245 | config.signal = abortControllerRef.current.signal 246 | 247 | return config 248 | }, 249 | [cancelOutstandingRequest, options.autoCancel] 250 | ) 251 | 252 | React.useEffect(() => { 253 | if (!options.manual) { 254 | request(withAbortSignal(config), options, dispatch).catch(() => {}) 255 | } 256 | 257 | return () => { 258 | if (options.autoCancel) { 259 | cancelOutstandingRequest() 260 | } 261 | } 262 | }, [config, options, withAbortSignal, cancelOutstandingRequest]) 263 | 264 | const refetch = React.useCallback( 265 | (configOverride, options) => { 266 | configOverride = configToObject(configOverride) 267 | 268 | return request( 269 | withAbortSignal({ 270 | ...config, 271 | ...(isReactEvent(configOverride) ? null : configOverride) 272 | }), 273 | { useCache: false, ...options }, 274 | dispatch 275 | ) 276 | }, 277 | [config, withAbortSignal] 278 | ) 279 | 280 | return [state, refetch, cancelOutstandingRequest] 281 | } 282 | } 283 | 284 | function useDeepCompareMemoize(value) { 285 | const ref = React.useRef() 286 | const signalRef = React.useRef(0) 287 | 288 | if (!deepEqual(value, ref.current)) { 289 | ref.current = value 290 | signalRef.current += 1 291 | } 292 | 293 | return [signalRef.current] 294 | } 295 | -------------------------------------------------------------------------------- /test-d/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectAssignable, expectType } from 'tsd' 2 | import { AxiosError, AxiosResponse } from 'axios' 3 | 4 | import useAxios from '../src' 5 | 6 | useAxios('') 7 | useAxios( 8 | { url: '' }, 9 | { autoCancel: true, manual: true, ssr: true, useCache: true } 10 | ) 11 | 12 | const [{ data, loading, error, response }, refetch, cancel] = useAxios('') 13 | 14 | expectType(data) 15 | expectType(loading) 16 | expectAssignable | null>(error) 17 | expectAssignable(response) 18 | expectAssignable(refetch) 19 | expectAssignable(cancel) 20 | 21 | refetch('') 22 | refetch({ url: '' }, { useCache: true }) 23 | refetch(new MouseEvent('click')) 24 | cancel() 25 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:react/recommended"], 3 | "parserOptions": { 4 | "ecmaFeatures": { 5 | "jsx": true 6 | } 7 | }, 8 | "env": { 9 | "jest": true 10 | }, 11 | "settings": { 12 | "react": { 13 | "version": "detect" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/axios.d.ts: -------------------------------------------------------------------------------- 1 | import { AxiosStatic } from 'axios' 2 | 3 | declare module 'axios' { 4 | interface AxiosStatic { 5 | mockResolvedValue: Function 6 | mockResolvedValueOnce: Function 7 | mockRejectedValue: Function 8 | mockRejectedValueOnce: Function 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/index.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import axios, { CanceledError } from 'axios' 3 | import { render, fireEvent } from '@testing-library/react' 4 | import { renderHook, act } from '@testing-library/react-hooks' 5 | 6 | import defaultUseAxios, { 7 | configure as defaultConfigure, 8 | resetConfigure as defaultResetConfigure, 9 | clearCache as defaultClearCache, 10 | loadCache as defaultLoadCache, 11 | serializeCache as defaultSerializeCache, 12 | makeUseAxios 13 | } from '../src' 14 | import { LRUCache } from 'lru-cache' 15 | 16 | jest.mock('axios') 17 | 18 | let errors 19 | let abortSpy 20 | 21 | beforeEach(() => { 22 | abortSpy = jest.spyOn(AbortController.prototype, 'abort') 23 | }) 24 | 25 | beforeAll(() => { 26 | const error = console.error 27 | 28 | console.error = (...args) => { 29 | error.apply(console, args) // keep default behaviour 30 | errors.push(args) 31 | } 32 | }) 33 | 34 | beforeEach(() => { 35 | errors = [] 36 | }) 37 | 38 | afterEach(() => { 39 | // assert that no errors were logged during tests 40 | expect(errors).toEqual([]) 41 | }) 42 | 43 | describe('default useAxios', () => { 44 | standardTests( 45 | defaultUseAxios, 46 | defaultConfigure, 47 | defaultResetConfigure, 48 | defaultClearCache, 49 | defaultLoadCache, 50 | defaultSerializeCache 51 | ) 52 | }) 53 | 54 | describe('makeUseAxios', () => { 55 | it('should be a function', () => { 56 | expect(makeUseAxios).toBeInstanceOf(Function) 57 | }) 58 | 59 | it('should not throw', () => { 60 | expect(makeUseAxios()).toBeTruthy() 61 | }) 62 | 63 | it('should provide a custom implementation of axios', () => { 64 | const mockAxios = jest.fn().mockResolvedValueOnce({ data: 'whatever' }) 65 | 66 | const setup = makeSetup(makeUseAxios({ axios: mockAxios })) 67 | 68 | const { waitForNextUpdate } = setup('') 69 | 70 | expect(mockAxios).toHaveBeenCalled() 71 | 72 | return waitForNextUpdate() 73 | }) 74 | 75 | describe('globally disabled cache', () => { 76 | let setup 77 | 78 | beforeEach(() => { 79 | setup = makeSetup(makeUseAxios({ cache: false })) 80 | }) 81 | 82 | it('should use local state across rerenders', async () => { 83 | axios.mockResolvedValueOnce({ data: 'whatever' }) 84 | 85 | const { waitForNextUpdate, rerender } = setup('') 86 | 87 | await waitForNextUpdate() 88 | 89 | rerender() 90 | 91 | expect(axios).toHaveBeenCalledTimes(1) 92 | }) 93 | 94 | it('should hit network across component mounts', async () => { 95 | axios.mockResolvedValue({ data: 'whatever' }) 96 | 97 | const { waitForNextUpdate, unmount } = setup('') 98 | 99 | await waitForNextUpdate() 100 | 101 | unmount() 102 | 103 | await setup('').waitForNextUpdate() 104 | 105 | expect(axios).toHaveBeenCalledTimes(2) 106 | }) 107 | }) 108 | 109 | describe('default hook options', () => { 110 | describe('manual', () => { 111 | it('should override default manual option', () => { 112 | const setup = makeSetup( 113 | makeUseAxios({ defaultOptions: { manual: true } }) 114 | ) 115 | 116 | setup('') 117 | 118 | expect(axios).not.toHaveBeenCalled() 119 | }) 120 | }) 121 | 122 | describe('useCache', () => { 123 | it('should override default useCache option', async () => { 124 | const setup = makeSetup( 125 | makeUseAxios({ defaultOptions: { useCache: false } }) 126 | ) 127 | 128 | axios.mockResolvedValue({ data: 'whatever' }) 129 | 130 | const { waitForNextUpdate, unmount } = setup('') 131 | 132 | await waitForNextUpdate() 133 | 134 | unmount() 135 | 136 | await setup('').waitForNextUpdate() 137 | 138 | expect(axios).toHaveBeenCalledTimes(2) 139 | }) 140 | }) 141 | 142 | describe('ssr', () => { 143 | it('should be able to set ssr option', () => { 144 | makeSetup(makeUseAxios({ defaultOptions: { ssr: false } })) 145 | }) 146 | }) 147 | }) 148 | 149 | describe('standard tests', () => { 150 | const useAxios = makeUseAxios() 151 | 152 | standardTests( 153 | useAxios, 154 | useAxios.configure, 155 | useAxios.resetConfigure, 156 | useAxios.clearCache, 157 | useAxios.loadCache, 158 | useAxios.serializeCache 159 | ) 160 | 161 | describe('with custom configuration', () => { 162 | const useAxios = makeUseAxios({ axios }) 163 | 164 | standardTests( 165 | useAxios, 166 | useAxios.configure, 167 | useAxios.resetConfigure, 168 | useAxios.clearCache, 169 | useAxios.loadCache, 170 | useAxios.serializeCache 171 | ) 172 | }) 173 | }) 174 | }) 175 | 176 | function makeSetup(useAxios) { 177 | return (config, options = undefined) => 178 | renderHook( 179 | ({ config, options }) => { 180 | return useAxios(config, options) 181 | }, 182 | { 183 | initialProps: { config, options } 184 | } 185 | ) 186 | } 187 | 188 | function standardTests( 189 | useAxios, 190 | configure, 191 | resetConfigure, 192 | clearCache, 193 | loadCache, 194 | serializeCache 195 | ) { 196 | const setup = makeSetup(useAxios) 197 | 198 | beforeEach(clearCache) 199 | 200 | describe('basic functionality', () => { 201 | it('should set loading to true and error to null before the request resolves', async () => { 202 | axios.mockResolvedValueOnce({ data: 'whatever' }) 203 | 204 | const { result, waitForNextUpdate } = setup('') 205 | 206 | expect(result.current[0].loading).toBe(true) 207 | expect(result.current[0].error).toBe(null) 208 | 209 | await waitForNextUpdate() 210 | }) 211 | 212 | it('should set loading to false when request resolves', async () => { 213 | axios.mockResolvedValueOnce({ data: 'whatever' }) 214 | 215 | const { result, waitForNextUpdate } = setup('') 216 | 217 | await waitForNextUpdate() 218 | 219 | expect(result.current[0].loading).toBe(false) 220 | expect(result.current[0].error).toBe(null) 221 | expect(result.current[0].data).toBe('whatever') 222 | }) 223 | 224 | it('should set the response', async () => { 225 | const response = { data: 'whatever' } 226 | 227 | axios.mockResolvedValueOnce(response) 228 | 229 | const { result, waitForNextUpdate } = setup('') 230 | 231 | await waitForNextUpdate() 232 | 233 | expect(result.current[0].loading).toBe(false) 234 | expect(result.current[0].error).toBe(null) 235 | expect(result.current[0].response).toBe(response) 236 | }) 237 | 238 | it('should set error when request fails', async () => { 239 | const error = new Error('boom') 240 | 241 | axios.mockRejectedValueOnce(error) 242 | 243 | const { result, waitForNextUpdate } = setup('') 244 | 245 | await waitForNextUpdate() 246 | 247 | expect(result.current[0].error).toBe(error) 248 | }) 249 | 250 | it('should not reset error when component rerenders', async () => { 251 | const error = new Error('boom') 252 | 253 | axios.mockRejectedValueOnce(error) 254 | 255 | const { result, waitForNextUpdate, rerender } = setup('') 256 | 257 | await waitForNextUpdate() 258 | 259 | expect(result.current[0].error).toBe(error) 260 | 261 | axios.mockResolvedValueOnce({ data: 'whatever' }) 262 | 263 | rerender() 264 | 265 | expect(result.current[0].error).toBe(error) 266 | }) 267 | 268 | it('should reset error when component remounts', async () => { 269 | const error = new Error('boom') 270 | 271 | axios.mockRejectedValueOnce(error) 272 | 273 | const firstRender = setup('') 274 | 275 | await firstRender.waitForNextUpdate() 276 | 277 | expect(firstRender.result.current[0].error).toBe(error) 278 | 279 | axios.mockResolvedValueOnce({ data: 'whatever' }) 280 | 281 | const secondRender = setup('') 282 | 283 | await secondRender.waitForNextUpdate() 284 | 285 | expect(secondRender.result.current[0].error).toBe(null) 286 | }) 287 | 288 | it('should reset error when refetch succeeds', async () => { 289 | const error = new Error('boom') 290 | 291 | axios.mockRejectedValueOnce(error) 292 | 293 | const { result, waitForNextUpdate } = setup('') 294 | 295 | await waitForNextUpdate() 296 | 297 | expect(result.current[0].error).toBe(error) 298 | 299 | axios.mockResolvedValueOnce({ data: 'whatever' }) 300 | 301 | // Refetch 302 | act(() => { 303 | result.current[1]() 304 | }) 305 | 306 | await waitForNextUpdate() 307 | 308 | expect(result.current[0].error).toBe(null) 309 | }) 310 | 311 | it('should set loading to false when request completes and returns error', async () => { 312 | const error = new Error('boom') 313 | 314 | axios.mockRejectedValueOnce(error) 315 | 316 | const { result, waitForNextUpdate } = setup('') 317 | 318 | await waitForNextUpdate() 319 | 320 | expect(result.current[0].loading).toBe(false) 321 | expect(result.current[0].error).toBe(error) 322 | }) 323 | 324 | it('should refetch', async () => { 325 | axios.mockResolvedValue({ data: 'whatever' }) 326 | 327 | const { result, waitForNextUpdate } = setup('') 328 | 329 | await waitForNextUpdate() 330 | 331 | act(() => { 332 | result.current[1]() 333 | }) 334 | 335 | expect(result.current[0].loading).toBe(true) 336 | expect(axios).toHaveBeenCalledTimes(2) 337 | 338 | await waitForNextUpdate() 339 | }) 340 | 341 | it('should return the same reference to the fetch function', async () => { 342 | axios.mockResolvedValue({ data: 'whatever' }) 343 | 344 | const { result, rerender, waitForNextUpdate } = setup('') 345 | 346 | const firstRefetch = result.current[1] 347 | 348 | rerender() 349 | 350 | expect(result.current[1]).toBe(firstRefetch) 351 | 352 | await waitForNextUpdate() 353 | }) 354 | 355 | it('should return the cached response on a new render', async () => { 356 | const response = { data: 'whatever' } 357 | 358 | axios.mockResolvedValueOnce(response) 359 | 360 | await setup('').waitForNextUpdate() 361 | 362 | const { result } = setup('') 363 | 364 | expect(result.current[0]).toEqual({ 365 | loading: false, 366 | error: null, 367 | data: response.data, 368 | response 369 | }) 370 | }) 371 | }) 372 | 373 | describe('request cancellation', () => { 374 | describe('effect-generated requests', () => { 375 | it('should skip default cancellation before request if options.autoCancel is set to false', async () => { 376 | axios.mockResolvedValue({ data: 'whatever' }) 377 | 378 | const { waitForNextUpdate, rerender } = setup('', { 379 | autoCancel: false 380 | }) 381 | 382 | await waitForNextUpdate() 383 | 384 | rerender() 385 | 386 | expect(abortSpy).not.toHaveBeenCalled() 387 | }) 388 | 389 | it('should skip default cancellation after unmount if options.autoCancel is set to false', async () => { 390 | axios.mockResolvedValue({ data: 'whatever' }) 391 | 392 | const { waitForNextUpdate, unmount } = setup('', { 393 | autoCancel: false 394 | }) 395 | 396 | await waitForNextUpdate() 397 | 398 | unmount() 399 | 400 | expect(abortSpy).not.toHaveBeenCalled() 401 | }) 402 | 403 | it('should provide the abort signal to axios', async () => { 404 | axios.mockResolvedValueOnce({ data: 'whatever' }) 405 | 406 | const { waitForNextUpdate } = setup('') 407 | 408 | expect(axios).toHaveBeenCalledWith( 409 | expect.objectContaining({ 410 | signal: expect.any(AbortSignal) 411 | }) 412 | ) 413 | 414 | await waitForNextUpdate() 415 | }) 416 | 417 | it('should cancel the outstanding request when the component unmounts', async () => { 418 | axios.mockResolvedValueOnce({ data: 'whatever' }) 419 | 420 | const { waitForNextUpdate, unmount } = setup('') 421 | 422 | await waitForNextUpdate() 423 | 424 | unmount() 425 | 426 | expect(abortSpy).toHaveBeenCalled() 427 | }) 428 | 429 | it('should cancel the outstanding request when the cancel method is called', async () => { 430 | axios.mockResolvedValue({ data: 'whatever' }) 431 | 432 | const { waitForNextUpdate, result } = setup('') 433 | 434 | await waitForNextUpdate() 435 | 436 | result.current[2]() 437 | 438 | expect(abortSpy).toHaveBeenCalled() 439 | }) 440 | 441 | it('should cancel the outstanding request when the component refetches due to a rerender', async () => { 442 | axios.mockResolvedValue({ data: 'whatever' }) 443 | 444 | const { waitForNextUpdate, rerender } = setup('initial config') 445 | 446 | await waitForNextUpdate() 447 | 448 | rerender({ config: 'new config', options: {} }) 449 | 450 | expect(abortSpy).toHaveBeenCalled() 451 | 452 | await waitForNextUpdate() 453 | }) 454 | 455 | it('should not cancel the outstanding request when the component rerenders with same string config', async () => { 456 | axios.mockResolvedValue({ data: 'whatever' }) 457 | 458 | const { waitForNextUpdate, rerender } = setup('initial config') 459 | 460 | await waitForNextUpdate() 461 | 462 | rerender() 463 | 464 | expect(abortSpy).not.toHaveBeenCalled() 465 | }) 466 | 467 | it('should not cancel the outstanding request when the component rerenders with same object config', async () => { 468 | axios.mockResolvedValue({ data: 'whatever' }) 469 | 470 | const { waitForNextUpdate, rerender } = setup({ some: 'config' }) 471 | 472 | await waitForNextUpdate() 473 | 474 | rerender() 475 | 476 | expect(abortSpy).not.toHaveBeenCalled() 477 | }) 478 | 479 | it('should not cancel the outstanding request when the component rerenders with equal string config', async () => { 480 | axios.mockResolvedValue({ data: 'whatever' }) 481 | 482 | const { waitForNextUpdate, rerender } = setup('initial config', {}) 483 | 484 | await waitForNextUpdate() 485 | 486 | rerender({ config: 'initial config', options: {} }) 487 | 488 | expect(abortSpy).not.toHaveBeenCalled() 489 | }) 490 | 491 | it('should not cancel the outstanding request when the component rerenders with equal object config', async () => { 492 | axios.mockResolvedValue({ data: 'whatever' }) 493 | 494 | const { waitForNextUpdate, rerender } = setup({ some: 'config' }, {}) 495 | 496 | await waitForNextUpdate() 497 | 498 | rerender({ config: { some: 'config' }, options: {} }) 499 | 500 | expect(abortSpy).not.toHaveBeenCalled() 501 | }) 502 | 503 | it('should cancel the outstanding request when the cancel method is called after the component rerenders with same config', async () => { 504 | axios.mockResolvedValue({ data: 'whatever' }) 505 | 506 | const { waitForNextUpdate, rerender, result } = setup('initial config') 507 | 508 | await waitForNextUpdate() 509 | 510 | rerender() 511 | 512 | result.current[2]() 513 | 514 | expect(abortSpy).toHaveBeenCalled() 515 | }) 516 | 517 | it('should not dispatch an error when the request is canceled', async () => { 518 | const cancellation = new CanceledError('canceled') 519 | 520 | axios.mockRejectedValueOnce(cancellation) 521 | 522 | const { result, waitFor } = setup('') 523 | 524 | // if we cancel we won't dispatch the error, hence there's no state update 525 | // to wait for. yet, if we don't try to wait, we won't know if we're handling 526 | // the error properly because the return value will not have the error until a 527 | // state update happens. it would be great to have a better way to test this 528 | await act(async () => { 529 | await waitFor(() => { 530 | expect(result.current[0].error).toBeNull() 531 | }) 532 | }) 533 | }) 534 | }) 535 | 536 | describe('manual refetches', () => { 537 | it('should provide the abort signal to axios', async () => { 538 | const { result, waitForNextUpdate } = setup('', { manual: true }) 539 | 540 | axios.mockResolvedValueOnce({ data: 'whatever' }) 541 | 542 | act(() => { 543 | result.current[1]() 544 | }) 545 | 546 | expect(axios).toHaveBeenCalledTimes(1) 547 | 548 | expect(axios).toHaveBeenLastCalledWith( 549 | expect.objectContaining({ 550 | signal: expect.any(AbortSignal) 551 | }) 552 | ) 553 | 554 | await waitForNextUpdate() 555 | }) 556 | 557 | it('should cancel the outstanding manual refetch when the component unmounts', async () => { 558 | const { result, waitForNextUpdate, unmount } = setup('', { 559 | manual: true 560 | }) 561 | 562 | axios.mockResolvedValueOnce({ data: 'whatever' }) 563 | 564 | act(() => { 565 | result.current[1]() 566 | }) 567 | 568 | await waitForNextUpdate() 569 | 570 | unmount() 571 | 572 | expect(abortSpy).toHaveBeenCalled() 573 | }) 574 | 575 | it('should cancel the outstanding manual refetch when the component refetches', async () => { 576 | axios.mockResolvedValue({ data: 'whatever' }) 577 | 578 | const { result, waitForNextUpdate, rerender } = setup('') 579 | 580 | act(() => { 581 | result.current[1]() 582 | }) 583 | 584 | await waitForNextUpdate() 585 | 586 | rerender({ config: 'new config', options: {} }) 587 | 588 | expect(abortSpy).toHaveBeenCalled() 589 | 590 | await waitForNextUpdate() 591 | }) 592 | 593 | it('should cancel the outstanding manual refetch when the cancel method is called', async () => { 594 | axios.mockResolvedValue({ data: 'whatever' }) 595 | 596 | const { result, waitForNextUpdate } = setup('', { manual: true }) 597 | 598 | act(() => { 599 | result.current[1]() 600 | }) 601 | 602 | await waitForNextUpdate() 603 | 604 | result.current[2]() 605 | 606 | expect(abortSpy).toHaveBeenCalled() 607 | }) 608 | 609 | it('should throw an error when the request is canceled', async () => { 610 | const cancellation = new CanceledError('canceled') 611 | 612 | axios.mockRejectedValueOnce(cancellation) 613 | 614 | const { result } = renderHook(() => useAxios('', { manual: true })) 615 | 616 | expect(() => act(result.current[1])).rejects.toBe(cancellation) 617 | }) 618 | 619 | it('should return response from cache in hook results', async () => { 620 | const response = { data: 'whatever' } 621 | 622 | axios.mockResolvedValueOnce(response) 623 | 624 | // first component renders and stores results in cache 625 | await setup('').waitForNextUpdate() 626 | 627 | const { result } = setup('', { manual: true }) 628 | 629 | // no results on first render as it's a manual request 630 | expect(result.current[0]).toEqual({ loading: false, error: null }) 631 | 632 | // refetch using cache 633 | act(() => { 634 | result.current[1]({}, { useCache: true }) 635 | }) 636 | 637 | expect(result.current[0]).toEqual({ 638 | loading: false, 639 | error: null, 640 | response, 641 | data: response.data 642 | }) 643 | }) 644 | }) 645 | }) 646 | 647 | describe('refetch', () => { 648 | describe('when axios resolves', () => { 649 | it('should resolve to the response by default', async () => { 650 | const response = { data: 'whatever' } 651 | 652 | axios.mockResolvedValue(response) 653 | 654 | const { 655 | result: { 656 | current: [, refetch] 657 | }, 658 | waitForNextUpdate 659 | } = setup('') 660 | 661 | act(() => { 662 | expect(refetch()).resolves.toEqual(response) 663 | }) 664 | 665 | await waitForNextUpdate() 666 | 667 | expect(axios).toHaveBeenCalledTimes(2) 668 | }) 669 | 670 | it('should resolve to the response when using cache', async () => { 671 | const response = { data: 'whatever' } 672 | 673 | axios.mockResolvedValue(response) 674 | 675 | const { 676 | result: { 677 | current: [, refetch] 678 | }, 679 | waitForNextUpdate 680 | } = setup('') 681 | 682 | await waitForNextUpdate() 683 | 684 | act(() => { 685 | expect(refetch({}, { useCache: true })).resolves.toEqual(response) 686 | }) 687 | 688 | expect(axios).toHaveBeenCalledTimes(1) 689 | }) 690 | }) 691 | 692 | describe('when axios rejects', () => { 693 | it('should reject with the error by default', async () => { 694 | const error = new Error('boom') 695 | 696 | axios.mockRejectedValue(error) 697 | 698 | const { 699 | result: { 700 | current: [, refetch] 701 | }, 702 | waitForNextUpdate 703 | } = setup('') 704 | 705 | await waitForNextUpdate() 706 | 707 | act(() => { 708 | expect(refetch()).rejects.toEqual(error) 709 | }) 710 | 711 | await waitForNextUpdate() 712 | }) 713 | 714 | it('should reject with the error and skip cache even when using cache', async () => { 715 | const error = new Error('boom') 716 | 717 | axios.mockRejectedValue(error) 718 | 719 | const { 720 | result: { 721 | current: [, refetch] 722 | }, 723 | waitForNextUpdate 724 | } = setup('') 725 | 726 | await waitForNextUpdate() 727 | 728 | act(() => { 729 | expect(refetch({}, { useCache: true })).rejects.toEqual(error) 730 | }) 731 | 732 | await waitForNextUpdate() 733 | }) 734 | }) 735 | 736 | describe('configuration override handling', () => { 737 | it('should override url', async () => { 738 | const response = { data: 'whatever' } 739 | 740 | axios.mockResolvedValue(response) 741 | 742 | const { 743 | result: { 744 | current: [, refetch] 745 | }, 746 | waitForNextUpdate 747 | } = setup('some url') 748 | 749 | act(() => { 750 | expect(refetch('some other url')).resolves.toEqual(response) 751 | }) 752 | 753 | await waitForNextUpdate() 754 | 755 | expect(axios).toHaveBeenNthCalledWith( 756 | 1, 757 | expect.objectContaining({ url: 'some url' }) 758 | ) 759 | expect(axios).toHaveBeenNthCalledWith( 760 | 2, 761 | expect.objectContaining({ url: 'some other url' }) 762 | ) 763 | }) 764 | 765 | it('should merge with the existing configuration', async () => { 766 | const response = { data: 'whatever' } 767 | 768 | axios.mockResolvedValue(response) 769 | 770 | const { 771 | result: { 772 | current: [, refetch] 773 | }, 774 | waitForNextUpdate 775 | } = setup('some url') 776 | 777 | act(() => { 778 | expect(refetch({ params: { some: 'param' } })).resolves.toEqual( 779 | response 780 | ) 781 | }) 782 | 783 | await waitForNextUpdate() 784 | 785 | expect(axios).toHaveBeenNthCalledWith( 786 | 1, 787 | expect.objectContaining({ url: 'some url' }) 788 | ) 789 | expect(axios).toHaveBeenNthCalledWith( 790 | 2, 791 | expect.objectContaining({ 792 | url: 'some url', 793 | params: { some: 'param' } 794 | }) 795 | ) 796 | }) 797 | 798 | it('should ignore config override if it is an event', async () => { 799 | const response = { data: 'whatever' } 800 | 801 | axios.mockResolvedValue(response) 802 | 803 | const { 804 | result: { 805 | current: [, refetch] 806 | }, 807 | waitForNextUpdate 808 | } = setup('some url') 809 | 810 | const handleClick = jest.fn(e => e.persist()) 811 | 812 | fireEvent.click( 813 | render(